Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/gittensory-miner/bin/gittensory-miner.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,26 @@ 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,
startUpdateCheck,
} 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;
Expand Down
2 changes: 2 additions & 0 deletions packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> --input <json> [--json]",
" gittensory-miner state get <owner/repo> [--json]",
" gittensory-miner state set <owner/repo> <idle|discovering|planning|preparing> [--json]",
Expand Down
23 changes: 23 additions & 0 deletions packages/gittensory-miner/lib/status.d.ts
Original file line number Diff line number Diff line change
@@ -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, string | undefined>): string;

export function collectStatus(env?: Record<string, string | undefined>, cwd?: string): MinerStatus;

export function runStatus(args?: string[], env?: Record<string, string | undefined>, cwd?: string): number;

export function runDoctorChecks(env?: Record<string, string | undefined>): DoctorCheck[];

export function runDoctor(args?: string[], env?: Record<string, string | undefined>): number;
145 changes: 145 additions & 0 deletions packages/gittensory-miner/lib/status.js
Original file line number Diff line number Diff line change
@@ -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("<pkg>/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;
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
86 changes: 86 additions & 0 deletions test/unit/miner-status.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading