diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 2f615aa4a4..cf09f37871 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -3,6 +3,7 @@ import { createRequire } from "node:module"; import { printHelp, printVersion, runCli } from "../lib/cli.js"; import { runDenyCheck } from "../lib/deny-check.js"; import { runStateCli } from "../lib/run-state-cli.js"; +import { runDoctor, runStatus } from "../lib/status.js"; import { awaitOpportunisticUpdateCheck, resolveUpgradeCommand, @@ -10,6 +11,18 @@ import { } from "../lib/update-check.js"; const cliArgs = process.argv.slice(2); + +// `status` and `doctor` are strictly local, offline commands — their contract is to make NO network calls. Dispatch +// them BEFORE the opportunistic npm-registry update check is even started, so they can never reach that network +// path (the update check runs for the remaining commands below). +if (cliArgs[0] === "status") { + process.exit(runStatus(cliArgs.slice(1))); +} + +if (cliArgs[0] === "doctor") { + process.exit(runDoctor(cliArgs.slice(1))); +} + const require = createRequire(import.meta.url); const packageName = "@jsonbored/gittensory-miner"; const packageVersion = require("../package.json").version; diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index b3a7de94c1..4769680e8d 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -14,6 +14,8 @@ export function printHelp(input) { " gittensory-miner --version", " gittensory-miner help", " gittensory-miner version", + " gittensory-miner status [--json] Show installed versions + local state paths", + " gittensory-miner doctor [--json] Check this laptop is set up correctly", " gittensory-miner hooks check --tool --input [--json]", " gittensory-miner state get [--json]", " gittensory-miner state set [--json]", diff --git a/packages/gittensory-miner/lib/status.d.ts b/packages/gittensory-miner/lib/status.d.ts new file mode 100644 index 0000000000..62edb3e2b0 --- /dev/null +++ b/packages/gittensory-miner/lib/status.d.ts @@ -0,0 +1,23 @@ +export type MinerStatus = { + package: { name: string; version: string | null }; + engine: { name: string; version: string | null }; + node: string; + stateDir: string; + configFile: string | null; +}; + +export type DoctorCheck = { + name: string; + ok: boolean; + detail: string; +}; + +export function resolveMinerStateDir(env?: Record): string; + +export function collectStatus(env?: Record, cwd?: string): MinerStatus; + +export function runStatus(args?: string[], env?: Record, cwd?: string): number; + +export function runDoctorChecks(env?: Record): DoctorCheck[]; + +export function runDoctor(args?: string[], env?: Record): number; diff --git a/packages/gittensory-miner/lib/status.js b/packages/gittensory-miner/lib/status.js new file mode 100644 index 0000000000..19755da74d --- /dev/null +++ b/packages/gittensory-miner/lib/status.js @@ -0,0 +1,145 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// Slim laptop-mode CLI commands (#2288): `status` (what's installed + where local state lives) and `doctor` (is +// this laptop set up correctly). Both are read-only and 100% local — no repo-scanning, no coding-agent invocation, +// no GitHub writes, and no network calls of any kind. Later phases add the real discover/plan/manage loop. + +const require = createRequire(import.meta.url); + +const PACKAGE_NAME = "@jsonbored/gittensory-miner"; +const ENGINE_PACKAGE = "@jsonbored/gittensory-engine"; +// Config-file discovery order (mirrors the `.gittensory-miner.yml` precedence the goal-spec parser documents). +const CONFIG_FILE_CANDIDATES = Object.freeze([ + ".gittensory-miner.yml", + ".github/gittensory-miner.yml", + ".gittensory-miner.json", + ".github/gittensory-miner.json", +]); + +/** The miner's local-state directory (holds the run-state / queue / ledger SQLite files). */ +export function resolveMinerStateDir(env = process.env) { + const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string" + ? env.GITTENSORY_MINER_CONFIG_DIR.trim() + : ""; + if (explicitConfigDir) return explicitConfigDir; + + const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim() + ? env.XDG_CONFIG_HOME.trim() + : join(homedir(), ".config"); + return join(configHome, "gittensory-miner"); +} + +function readOwnVersion() { + try { + return require("../package.json").version ?? null; + } catch { + return null; + } +} + +// The pinned @jsonbored/gittensory-engine version this miner is built against, read from the miner's own declared +// dependency. (The engine package's `exports` map blocks `require("/package.json")`, and its built `dist` may +// be absent depending on build order, so the declared-dependency version is the reliable, always-available source.) +function readEngineVersion() { + try { + return require("../package.json").dependencies?.[ENGINE_PACKAGE] ?? null; + } catch { + return null; + } +} + +/** The minimum Node major version from the package's `engines.node` floor (e.g. ">=22.13.0" → 22). */ +function requiredNodeMajor() { + const engines = require("../package.json").engines; + const match = typeof engines?.node === "string" ? engines.node.match(/(\d+)/) : null; + return match ? Number(match[1]) : 0; +} + +function discoverConfigFile(cwd) { + for (const candidate of CONFIG_FILE_CANDIDATES) { + const path = join(cwd, candidate); + if (existsSync(path)) return path; + } + return null; +} + +/** Gather the read-only status snapshot. Pure w.r.t. its (env, cwd) inputs — no writes, no network. */ +export function collectStatus(env = process.env, cwd = process.cwd()) { + const stateDir = resolveMinerStateDir(env); + return { + package: { name: PACKAGE_NAME, version: readOwnVersion() }, + engine: { name: ENGINE_PACKAGE, version: readEngineVersion() }, + node: process.version, + stateDir, + configFile: discoverConfigFile(cwd), + }; +} + +function renderStatusText(status) { + return [ + `${status.package.name} ${status.package.version ?? "unknown"} (node ${status.node})`, + `engine: ${status.engine.name} ${status.engine.version ?? "unresolved"}`, + `state dir: ${status.stateDir}`, + `config file: ${status.configFile ?? "none found"}`, + ].join("\n"); +} + +export function runStatus(args = [], env = process.env, cwd = process.cwd()) { + const status = collectStatus(env, cwd); + console.log(args.includes("--json") ? JSON.stringify(status, null, 2) : renderStatusText(status)); + return 0; +} + +function checkStateDirWritable(stateDir) { + const probe = join(stateDir, ".gittensory-miner-write-probe"); + try { + // Creating the dir and writing (then removing) a probe file proves it is writable — the state dir must be + // creatable/writable for the local SQLite stores to work. + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + writeFileSync(probe, ""); + rmSync(probe, { force: true }); + return { name: "state-dir-writable", ok: true, detail: stateDir }; + } catch (error) { + return { + name: "state-dir-writable", + ok: false, + detail: `${stateDir}: ${error instanceof Error ? error.message : "not writable"}`, + }; + } +} + +/** Run the doctor checks. Returns an array of { name, ok, detail }; only writes a transient probe in the state dir, + * never touches the network. */ +export function runDoctorChecks(env = process.env) { + const nodeMajor = Number(process.versions.node.split(".")[0]); + const requiredMajor = requiredNodeMajor(); + const engineVersion = readEngineVersion(); + return [ + { + name: "node-version", + ok: nodeMajor >= requiredMajor, + detail: `node ${process.version} (requires >= ${requiredMajor})`, + }, + { + name: "engine-resolves", + ok: engineVersion !== null, + detail: engineVersion ? `${ENGINE_PACKAGE} ${engineVersion}` : `${ENGINE_PACKAGE} not resolvable`, + }, + checkStateDirWritable(resolveMinerStateDir(env)), + ]; +} + +export function runDoctor(args = [], env = process.env) { + const checks = runDoctorChecks(env); + const failed = checks.filter((check) => !check.ok); + if (args.includes("--json")) { + console.log(JSON.stringify({ ok: failed.length === 0, checks }, null, 2)); + } else { + for (const check of checks) console.log(`${check.ok ? "ok " : "FAIL"} ${check.name}: ${check.detail}`); + if (failed.length > 0) console.error(`doctor: ${failed.length} check(s) failed`); + } + return failed.length === 0 ? 0 : 1; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 0d3cf6c1f5..a4003e613d 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -31,7 +31,7 @@ "lib" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/claim-ledger.js && node --check lib/portfolio-queue.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/claim-ledger.js && node --check lib/portfolio-queue.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/status.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts new file mode 100644 index 0000000000..276841d411 --- /dev/null +++ b/test/unit/miner-status.test.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + collectStatus, + resolveMinerStateDir, + runDoctor, + runDoctorChecks, + runStatus, +} from "../../packages/gittensory-miner/lib/status.js"; + +const roots: string[] = []; + +function tempRoot() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-status-")); + roots.push(root); + return root; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("gittensory-miner status/doctor (#2288)", () => { + it("resolves the state dir from the config-dir override, XDG, then the home default", () => { + expect(resolveMinerStateDir({ GITTENSORY_MINER_CONFIG_DIR: "/custom/state" })).toBe("/custom/state"); + expect(resolveMinerStateDir({ XDG_CONFIG_HOME: "/xdg" })).toBe("/xdg/gittensory-miner"); + expect(resolveMinerStateDir({})).toMatch(/\/\.config\/gittensory-miner$/); + }); + + it("collectStatus reports the installed versions, state dir, and config-file discovery", () => { + const root = tempRoot(); + writeFileSync(join(root, ".gittensory-miner.yml"), "minerEnabled: true\n"); + const status = collectStatus({ GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }, root); + expect(status.package.name).toBe("@jsonbored/gittensory-miner"); + expect(typeof status.package.version).toBe("string"); + expect(status.engine.name).toBe("@jsonbored/gittensory-engine"); + expect(status.stateDir).toBe(join(root, "state")); + expect(status.configFile).toBe(join(root, ".gittensory-miner.yml")); // discovered + }); + + it("runStatus prints human-readable text (0) and machine JSON with --json", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect(runStatus([], { GITTENSORY_MINER_CONFIG_DIR: "/s" }, tempRoot())).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toContain("@jsonbored/gittensory-miner"); + log.mockClear(); + expect(runStatus(["--json"], { GITTENSORY_MINER_CONFIG_DIR: "/s" }, tempRoot())).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0])).stateDir).toBe("/s"); + }); + + it("doctor passes on a healthy setup (writable state dir under this Node)", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const checks = runDoctorChecks({ GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }); + expect(checks.every((check) => check.ok)).toBe(true); + expect(checks.map((check) => check.name)).toEqual(["node-version", "engine-resolves", "state-dir-writable"]); + expect(runDoctor([], { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") })).toBe(0); + expect(log).toHaveBeenCalled(); + }); + + it("doctor fails (exit 1) when the state directory cannot be created", () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + // Point the state dir UNDER a regular file → mkdir throws ENOTDIR. + const root = tempRoot(); + const filePath = join(root, "not-a-dir"); + writeFileSync(filePath, ""); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(filePath, "state") }; + expect(runDoctorChecks(env).find((check) => check.name === "state-dir-writable")?.ok).toBe(false); + expect(runDoctor([], env)).toBe(1); + expect(errorLog).toHaveBeenCalled(); + }); + + it("makes no network calls", () => { + const fetchStub = vi.fn(() => { + throw new Error("network calls are forbidden"); + }); + vi.stubGlobal("fetch", fetchStub); + vi.spyOn(console, "log").mockImplementation(() => {}); + runStatus(["--json"], { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }, tempRoot()); + runDoctor([], { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }); + expect(fetchStub).not.toHaveBeenCalled(); + }); +});