|
| 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 | +} |
0 commit comments