Skip to content

Commit cea0943

Browse files
authored
feat(miner): add slim laptop-mode status and doctor CLI commands (#2866)
Add gittensory-miner status and gittensory-miner doctor: the miner CLI's first real commands. status (text or --json) prints the miner + pinned engine version, Node version, local-state directory, and discovered .gittensory-miner.yml config file. doctor checks Node major against the engines.node floor, the pinned engine version is readable, and the local-state directory is creatable/writable, exiting non-zero on failure. Both are read-only and 100% local — no repo-scanning, no GitHub writes, no network calls. lib/status.js holds pure, env/cwd-parameterized helpers; bin dispatches them like the existing hooks check. Closes #2288.
1 parent 72c10bf commit cea0943

6 files changed

Lines changed: 270 additions & 1 deletion

File tree

packages/gittensory-miner/bin/gittensory-miner.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,26 @@ import { createRequire } from "node:module";
33
import { printHelp, printVersion, runCli } from "../lib/cli.js";
44
import { runDenyCheck } from "../lib/deny-check.js";
55
import { runStateCli } from "../lib/run-state-cli.js";
6+
import { runDoctor, runStatus } from "../lib/status.js";
67
import {
78
awaitOpportunisticUpdateCheck,
89
resolveUpgradeCommand,
910
startUpdateCheck,
1011
} from "../lib/update-check.js";
1112

1213
const cliArgs = process.argv.slice(2);
14+
15+
// `status` and `doctor` are strictly local, offline commands — their contract is to make NO network calls. Dispatch
16+
// them BEFORE the opportunistic npm-registry update check is even started, so they can never reach that network
17+
// path (the update check runs for the remaining commands below).
18+
if (cliArgs[0] === "status") {
19+
process.exit(runStatus(cliArgs.slice(1)));
20+
}
21+
22+
if (cliArgs[0] === "doctor") {
23+
process.exit(runDoctor(cliArgs.slice(1)));
24+
}
25+
1326
const require = createRequire(import.meta.url);
1427
const packageName = "@jsonbored/gittensory-miner";
1528
const packageVersion = require("../package.json").version;

packages/gittensory-miner/lib/cli.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export function printHelp(input) {
1414
" gittensory-miner --version",
1515
" gittensory-miner help",
1616
" gittensory-miner version",
17+
" gittensory-miner status [--json] Show installed versions + local state paths",
18+
" gittensory-miner doctor [--json] Check this laptop is set up correctly",
1719
" gittensory-miner hooks check --tool <name> --input <json> [--json]",
1820
" gittensory-miner state get <owner/repo> [--json]",
1921
" gittensory-miner state set <owner/repo> <idle|discovering|planning|preparing> [--json]",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export type MinerStatus = {
2+
package: { name: string; version: string | null };
3+
engine: { name: string; version: string | null };
4+
node: string;
5+
stateDir: string;
6+
configFile: string | null;
7+
};
8+
9+
export type DoctorCheck = {
10+
name: string;
11+
ok: boolean;
12+
detail: string;
13+
};
14+
15+
export function resolveMinerStateDir(env?: Record<string, string | undefined>): string;
16+
17+
export function collectStatus(env?: Record<string, string | undefined>, cwd?: string): MinerStatus;
18+
19+
export function runStatus(args?: string[], env?: Record<string, string | undefined>, cwd?: string): number;
20+
21+
export function runDoctorChecks(env?: Record<string, string | undefined>): DoctorCheck[];
22+
23+
export function runDoctor(args?: string[], env?: Record<string, string | undefined>): number;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2+
import { createRequire } from "node:module";
3+
import { homedir } from "node:os";
4+
import { join } from "node:path";
5+
6+
// Slim laptop-mode CLI commands (#2288): `status` (what's installed + where local state lives) and `doctor` (is
7+
// this laptop set up correctly). Both are read-only and 100% local — no repo-scanning, no coding-agent invocation,
8+
// no GitHub writes, and no network calls of any kind. Later phases add the real discover/plan/manage loop.
9+
10+
const require = createRequire(import.meta.url);
11+
12+
const PACKAGE_NAME = "@jsonbored/gittensory-miner";
13+
const ENGINE_PACKAGE = "@jsonbored/gittensory-engine";
14+
// Config-file discovery order (mirrors the `.gittensory-miner.yml` precedence the goal-spec parser documents).
15+
const CONFIG_FILE_CANDIDATES = Object.freeze([
16+
".gittensory-miner.yml",
17+
".github/gittensory-miner.yml",
18+
".gittensory-miner.json",
19+
".github/gittensory-miner.json",
20+
]);
21+
22+
/** The miner's local-state directory (holds the run-state / queue / ledger SQLite files). */
23+
export function resolveMinerStateDir(env = process.env) {
24+
const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string"
25+
? env.GITTENSORY_MINER_CONFIG_DIR.trim()
26+
: "";
27+
if (explicitConfigDir) return explicitConfigDir;
28+
29+
const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()
30+
? env.XDG_CONFIG_HOME.trim()
31+
: join(homedir(), ".config");
32+
return join(configHome, "gittensory-miner");
33+
}
34+
35+
function readOwnVersion() {
36+
try {
37+
return require("../package.json").version ?? null;
38+
} catch {
39+
return null;
40+
}
41+
}
42+
43+
// The pinned @jsonbored/gittensory-engine version this miner is built against, read from the miner's own declared
44+
// dependency. (The engine package's `exports` map blocks `require("<pkg>/package.json")`, and its built `dist` may
45+
// be absent depending on build order, so the declared-dependency version is the reliable, always-available source.)
46+
function readEngineVersion() {
47+
try {
48+
return require("../package.json").dependencies?.[ENGINE_PACKAGE] ?? null;
49+
} catch {
50+
return null;
51+
}
52+
}
53+
54+
/** The minimum Node major version from the package's `engines.node` floor (e.g. ">=22.13.0" → 22). */
55+
function requiredNodeMajor() {
56+
const engines = require("../package.json").engines;
57+
const match = typeof engines?.node === "string" ? engines.node.match(/(\d+)/) : null;
58+
return match ? Number(match[1]) : 0;
59+
}
60+
61+
function discoverConfigFile(cwd) {
62+
for (const candidate of CONFIG_FILE_CANDIDATES) {
63+
const path = join(cwd, candidate);
64+
if (existsSync(path)) return path;
65+
}
66+
return null;
67+
}
68+
69+
/** Gather the read-only status snapshot. Pure w.r.t. its (env, cwd) inputs — no writes, no network. */
70+
export function collectStatus(env = process.env, cwd = process.cwd()) {
71+
const stateDir = resolveMinerStateDir(env);
72+
return {
73+
package: { name: PACKAGE_NAME, version: readOwnVersion() },
74+
engine: { name: ENGINE_PACKAGE, version: readEngineVersion() },
75+
node: process.version,
76+
stateDir,
77+
configFile: discoverConfigFile(cwd),
78+
};
79+
}
80+
81+
function renderStatusText(status) {
82+
return [
83+
`${status.package.name} ${status.package.version ?? "unknown"} (node ${status.node})`,
84+
`engine: ${status.engine.name} ${status.engine.version ?? "unresolved"}`,
85+
`state dir: ${status.stateDir}`,
86+
`config file: ${status.configFile ?? "none found"}`,
87+
].join("\n");
88+
}
89+
90+
export function runStatus(args = [], env = process.env, cwd = process.cwd()) {
91+
const status = collectStatus(env, cwd);
92+
console.log(args.includes("--json") ? JSON.stringify(status, null, 2) : renderStatusText(status));
93+
return 0;
94+
}
95+
96+
function checkStateDirWritable(stateDir) {
97+
const probe = join(stateDir, ".gittensory-miner-write-probe");
98+
try {
99+
// Creating the dir and writing (then removing) a probe file proves it is writable — the state dir must be
100+
// creatable/writable for the local SQLite stores to work.
101+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
102+
writeFileSync(probe, "");
103+
rmSync(probe, { force: true });
104+
return { name: "state-dir-writable", ok: true, detail: stateDir };
105+
} catch (error) {
106+
return {
107+
name: "state-dir-writable",
108+
ok: false,
109+
detail: `${stateDir}: ${error instanceof Error ? error.message : "not writable"}`,
110+
};
111+
}
112+
}
113+
114+
/** Run the doctor checks. Returns an array of { name, ok, detail }; only writes a transient probe in the state dir,
115+
* never touches the network. */
116+
export function runDoctorChecks(env = process.env) {
117+
const nodeMajor = Number(process.versions.node.split(".")[0]);
118+
const requiredMajor = requiredNodeMajor();
119+
const engineVersion = readEngineVersion();
120+
return [
121+
{
122+
name: "node-version",
123+
ok: nodeMajor >= requiredMajor,
124+
detail: `node ${process.version} (requires >= ${requiredMajor})`,
125+
},
126+
{
127+
name: "engine-resolves",
128+
ok: engineVersion !== null,
129+
detail: engineVersion ? `${ENGINE_PACKAGE} ${engineVersion}` : `${ENGINE_PACKAGE} not resolvable`,
130+
},
131+
checkStateDirWritable(resolveMinerStateDir(env)),
132+
];
133+
}
134+
135+
export function runDoctor(args = [], env = process.env) {
136+
const checks = runDoctorChecks(env);
137+
const failed = checks.filter((check) => !check.ok);
138+
if (args.includes("--json")) {
139+
console.log(JSON.stringify({ ok: failed.length === 0, checks }, null, 2));
140+
} else {
141+
for (const check of checks) console.log(`${check.ok ? "ok " : "FAIL"} ${check.name}: ${check.detail}`);
142+
if (failed.length > 0) console.error(`doctor: ${failed.length} check(s) failed`);
143+
}
144+
return failed.length === 0 ? 0 : 1;
145+
}

packages/gittensory-miner/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"lib"
3232
],
3333
"scripts": {
34-
"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"
34+
"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"
3535
},
3636
"dependencies": {
3737
"@jsonbored/gittensory-engine": "0.1.0"

test/unit/miner-status.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import {
6+
collectStatus,
7+
resolveMinerStateDir,
8+
runDoctor,
9+
runDoctorChecks,
10+
runStatus,
11+
} from "../../packages/gittensory-miner/lib/status.js";
12+
13+
const roots: string[] = [];
14+
15+
function tempRoot() {
16+
const root = mkdtempSync(join(tmpdir(), "gittensory-miner-status-"));
17+
roots.push(root);
18+
return root;
19+
}
20+
21+
afterEach(() => {
22+
vi.restoreAllMocks();
23+
vi.unstubAllGlobals();
24+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
25+
});
26+
27+
describe("gittensory-miner status/doctor (#2288)", () => {
28+
it("resolves the state dir from the config-dir override, XDG, then the home default", () => {
29+
expect(resolveMinerStateDir({ GITTENSORY_MINER_CONFIG_DIR: "/custom/state" })).toBe("/custom/state");
30+
expect(resolveMinerStateDir({ XDG_CONFIG_HOME: "/xdg" })).toBe("/xdg/gittensory-miner");
31+
expect(resolveMinerStateDir({})).toMatch(/\/\.config\/gittensory-miner$/);
32+
});
33+
34+
it("collectStatus reports the installed versions, state dir, and config-file discovery", () => {
35+
const root = tempRoot();
36+
writeFileSync(join(root, ".gittensory-miner.yml"), "minerEnabled: true\n");
37+
const status = collectStatus({ GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }, root);
38+
expect(status.package.name).toBe("@jsonbored/gittensory-miner");
39+
expect(typeof status.package.version).toBe("string");
40+
expect(status.engine.name).toBe("@jsonbored/gittensory-engine");
41+
expect(status.stateDir).toBe(join(root, "state"));
42+
expect(status.configFile).toBe(join(root, ".gittensory-miner.yml")); // discovered
43+
});
44+
45+
it("runStatus prints human-readable text (0) and machine JSON with --json", () => {
46+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
47+
expect(runStatus([], { GITTENSORY_MINER_CONFIG_DIR: "/s" }, tempRoot())).toBe(0);
48+
expect(String(log.mock.calls[0]?.[0])).toContain("@jsonbored/gittensory-miner");
49+
log.mockClear();
50+
expect(runStatus(["--json"], { GITTENSORY_MINER_CONFIG_DIR: "/s" }, tempRoot())).toBe(0);
51+
expect(JSON.parse(String(log.mock.calls[0]?.[0])).stateDir).toBe("/s");
52+
});
53+
54+
it("doctor passes on a healthy setup (writable state dir under this Node)", () => {
55+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
56+
const checks = runDoctorChecks({ GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") });
57+
expect(checks.every((check) => check.ok)).toBe(true);
58+
expect(checks.map((check) => check.name)).toEqual(["node-version", "engine-resolves", "state-dir-writable"]);
59+
expect(runDoctor([], { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") })).toBe(0);
60+
expect(log).toHaveBeenCalled();
61+
});
62+
63+
it("doctor fails (exit 1) when the state directory cannot be created", () => {
64+
vi.spyOn(console, "log").mockImplementation(() => {});
65+
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
66+
// Point the state dir UNDER a regular file → mkdir throws ENOTDIR.
67+
const root = tempRoot();
68+
const filePath = join(root, "not-a-dir");
69+
writeFileSync(filePath, "");
70+
const env = { GITTENSORY_MINER_CONFIG_DIR: join(filePath, "state") };
71+
expect(runDoctorChecks(env).find((check) => check.name === "state-dir-writable")?.ok).toBe(false);
72+
expect(runDoctor([], env)).toBe(1);
73+
expect(errorLog).toHaveBeenCalled();
74+
});
75+
76+
it("makes no network calls", () => {
77+
const fetchStub = vi.fn(() => {
78+
throw new Error("network calls are forbidden");
79+
});
80+
vi.stubGlobal("fetch", fetchStub);
81+
vi.spyOn(console, "log").mockImplementation(() => {});
82+
runStatus(["--json"], { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }, tempRoot());
83+
runDoctor([], { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") });
84+
expect(fetchStub).not.toHaveBeenCalled();
85+
});
86+
});

0 commit comments

Comments
 (0)