From 36abb6aff138e2389f522e3148896bfad4019951 Mon Sep 17 00:00:00 2001 From: kiannidev <156195510+kiannidev@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:04:34 +0200 Subject: [PATCH] feat(miner-packaging): add laptop-mode init command (#2329) Bootstrap local SQLite state with gittensory-miner init, extend doctor with sqlite and Docker checks, and document the zero-infra quickstart. Co-authored-by: Cursor --- packages/gittensory-miner/README.md | 16 +++ .../gittensory-miner/bin/gittensory-miner.js | 11 +- packages/gittensory-miner/lib/cli.js | 1 + .../gittensory-miner/lib/laptop-init.d.ts | 23 ++++ packages/gittensory-miner/lib/laptop-init.js | 101 +++++++++++++++ packages/gittensory-miner/lib/status.js | 3 + packages/gittensory-miner/package.json | 2 +- test/unit/miner-laptop-init.test.ts | 121 ++++++++++++++++++ test/unit/miner-status.test.ts | 23 +++- 9 files changed, 291 insertions(+), 10 deletions(-) create mode 100644 packages/gittensory-miner/lib/laptop-init.d.ts create mode 100644 packages/gittensory-miner/lib/laptop-init.js create mode 100644 test/unit/miner-laptop-init.test.ts diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index b53045754b..817df5b7de 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -34,6 +34,19 @@ See [`docs/miner-goal-spec.md`](docs/miner-goal-spec.md) for the `.gittensory-mi See [`DEPLOYMENT.md`](DEPLOYMENT.md) for laptop vs fleet deployment. +### Laptop-mode quickstart + +Zero-infra local install — no Docker, Redis, or Postgres required: + +```sh +npm install -g @jsonbored/gittensory-miner +gittensory-miner init +gittensory-miner doctor +gittensory-miner status +``` + +`init` creates `~/.config/gittensory-miner/` (or `GITTENSORY_MINER_CONFIG_DIR` / `XDG_CONFIG_HOME` overrides) and a local `laptop-state.sqlite3` bootstrap file. Re-running `init` is idempotent. `doctor` reports Node, the state directory, SQLite readiness, and whether Docker is installed (informational only). + From a local checkout: ```sh @@ -48,6 +61,9 @@ gittensory-miner --help gittensory-miner help gittensory-miner --version gittensory-miner version +gittensory-miner init [--json] +gittensory-miner status [--json] +gittensory-miner doctor [--json] ``` ## Version check diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 74a0096dde..0f008c1d2e 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -9,6 +9,7 @@ import { runManageStatus } from "../lib/manage-status.js"; import { runPlanCli } from "../lib/plan-store-cli.js"; import { runQueueCli } from "../lib/portfolio-queue-cli.js"; import { runStateCli } from "../lib/run-state-cli.js"; +import { runInit } from "../lib/laptop-init.js"; import { runDoctor, runStatus } from "../lib/status.js"; import { awaitOpportunisticUpdateCheck, @@ -18,9 +19,13 @@ import { 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). +// `init`, `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] === "init") { + process.exit(runInit(cliArgs.slice(1))); +} + if (cliArgs[0] === "status") { process.exit(runStatus(cliArgs.slice(1))); } diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 6f999371b5..dddf07a446 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -14,6 +14,7 @@ export function printHelp(input) { " gittensory-miner --version", " gittensory-miner help", " gittensory-miner version", + " gittensory-miner init [--json] Bootstrap laptop-mode local SQLite state", " gittensory-miner status [--json] Show installed versions + local state paths", " gittensory-miner doctor [--json] Check this laptop is set up correctly", " gittensory-miner manage status [--json] Show managed PR rows from local portfolio + ledger", diff --git a/packages/gittensory-miner/lib/laptop-init.d.ts b/packages/gittensory-miner/lib/laptop-init.d.ts new file mode 100644 index 0000000000..1ee7005b54 --- /dev/null +++ b/packages/gittensory-miner/lib/laptop-init.d.ts @@ -0,0 +1,23 @@ +export type LaptopInitResult = { + stateDir: string; + dbPath: string; + created: boolean; +}; + +export type DoctorCheck = { + name: string; + ok: boolean; + detail: string; +}; + +export function resolveLaptopStateDbPath(env?: Record): string; + +export function initLaptopState(env?: Record): LaptopInitResult; + +export function checkLaptopStateSqlite(env?: Record): DoctorCheck; + +export function checkDockerPresent(options?: { + resolveDockerPath?: () => string | null; +}): DoctorCheck; + +export function runInit(args?: string[], env?: Record): number; diff --git a/packages/gittensory-miner/lib/laptop-init.js b/packages/gittensory-miner/lib/laptop-init.js new file mode 100644 index 0000000000..0214b26bd7 --- /dev/null +++ b/packages/gittensory-miner/lib/laptop-init.js @@ -0,0 +1,101 @@ +import { execFileSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +const defaultDbFileName = "laptop-state.sqlite3"; + +/** Local state directory (mirrors `resolveMinerStateDir` in status.js — kept local to avoid import cycles). */ +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"); +} + +/** Path to the laptop-mode SQLite bootstrap file inside the miner state directory. */ +export function resolveLaptopStateDbPath(env = process.env) { + return join(resolveMinerStateDir(env), defaultDbFileName); +} + +/** Create the state dir and SQLite file. Re-running is idempotent and never clobbers existing rows. */ +export function initLaptopState(env = process.env) { + const stateDir = resolveMinerStateDir(env); + const dbPath = resolveLaptopStateDbPath(env); + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + const created = !existsSync(dbPath); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE IF NOT EXISTS laptop_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `); + if (created) { + db.prepare("INSERT INTO laptop_meta (key, value) VALUES ('initialized_at', ?)") + .run(new Date().toISOString()); + } + chmodSync(dbPath, 0o600); + db.close(); + return { stateDir, dbPath, created }; +} + +export function checkLaptopStateSqlite(env = process.env) { + const dbPath = resolveLaptopStateDbPath(env); + if (!existsSync(dbPath)) { + return { + name: "laptop-state-sqlite", + ok: false, + detail: `${dbPath}: not found (run gittensory-miner init)`, + }; + } + try { + const db = new DatabaseSync(dbPath, { readonly: true }); + db.prepare("SELECT 1").get(); + db.close(); + return { name: "laptop-state-sqlite", ok: true, detail: dbPath }; + } catch (error) { + return { + name: "laptop-state-sqlite", + ok: false, + detail: `${dbPath}: ${error instanceof Error ? error.message : "not readable"}`, + }; + } +} + +/** Informational only — Docker is never required for laptop mode. */ +export function checkDockerPresent(options = {}) { + const resolveDockerPath = options.resolveDockerPath ?? (() => { + try { + return execFileSync("which", ["docker"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() || null; + } catch { + return null; + } + }); + const dockerPath = resolveDockerPath(); + return { + name: "docker-present", + ok: true, + detail: dockerPath ? `found at ${dockerPath}` : "not installed (optional for laptop mode)", + }; +} + +export function runInit(args = [], env = process.env) { + const result = initLaptopState(env); + if (args.includes("--json")) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`initialized ${result.stateDir}`); + console.log(`sqlite: ${result.dbPath}${result.created ? "" : " (already existed)"}`); + } + return 0; +} diff --git a/packages/gittensory-miner/lib/status.js b/packages/gittensory-miner/lib/status.js index 19755da74d..76e0f9cc5f 100644 --- a/packages/gittensory-miner/lib/status.js +++ b/packages/gittensory-miner/lib/status.js @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { homedir } from "node:os"; import { join } from "node:path"; +import { checkDockerPresent, checkLaptopStateSqlite } from "./laptop-init.js"; // 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, @@ -129,6 +130,8 @@ export function runDoctorChecks(env = process.env) { detail: engineVersion ? `${ENGINE_PACKAGE} ${engineVersion}` : `${ENGINE_PACKAGE} not resolvable`, }, checkStateDirWritable(resolveMinerStateDir(env)), + checkLaptopStateSqlite(env), + checkDockerPresent(), ]; } diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 51b4353a02..3672db3642 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/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.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/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-laptop-init.test.ts b/test/unit/miner-laptop-init.test.ts new file mode 100644 index 0000000000..99294403d7 --- /dev/null +++ b/test/unit/miner-laptop-init.test.ts @@ -0,0 +1,121 @@ +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + checkDockerPresent, + checkLaptopStateSqlite, + initLaptopState, + resolveLaptopStateDbPath, + runInit, +} from "../../packages/gittensory-miner/lib/laptop-init.js"; + +const roots: string[] = []; + +function tempRoot() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-init-")); + 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 laptop init (#2329)", () => { + it("resolves the laptop SQLite path from the state-dir override and XDG fallback", () => { + expect(resolveLaptopStateDbPath({ GITTENSORY_MINER_CONFIG_DIR: "/custom/state" })) + .toBe("/custom/state/laptop-state.sqlite3"); + expect(resolveLaptopStateDbPath({ XDG_CONFIG_HOME: "/xdg" })) + .toBe("/xdg/gittensory-miner/laptop-state.sqlite3"); + }); + + it("fresh init creates the state dir and SQLite file", () => { + const root = tempRoot(); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; + const first = initLaptopState(env); + expect(first.created).toBe(true); + expect(existsSync(first.dbPath)).toBe(true); + expect(existsSync(first.stateDir)).toBe(true); + expect(checkLaptopStateSqlite(env).ok).toBe(true); + }); + + it("re-running init is idempotent and does not clobber existing metadata", () => { + const root = tempRoot(); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; + const first = initLaptopState(env); + writeFileSync(join(first.stateDir, "marker.txt"), "keep-me"); + const second = initLaptopState(env); + expect(second.created).toBe(false); + expect(readFileSync(join(first.stateDir, "marker.txt"), "utf8")).toBe("keep-me"); + }); + + it("runInit prints human text (0) and machine JSON with --json", () => { + const root = tempRoot(); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect(runInit([], env)).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toContain("initialized"); + log.mockClear(); + expect(runInit(["--json"], env)).toBe(0); + const payload = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(payload.created).toBe(false); + expect(payload.dbPath).toBe(resolveLaptopStateDbPath(env)); + }); + + it("doctor sqlite check reports a missing file with guidance", () => { + const root = tempRoot(); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; + const check = checkLaptopStateSqlite(env); + expect(check.ok).toBe(false); + expect(check.detail).toContain("gittensory-miner init"); + }); + + it("doctor sqlite check reports unreadable files", () => { + const root = tempRoot(); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; + const dbPath = resolveLaptopStateDbPath(env); + mkdirSync(join(root, "state"), { recursive: true }); + writeFileSync(dbPath, "not-a-sqlite-db"); + chmodSync(dbPath, 0o600); + const check = checkLaptopStateSqlite(env); + expect(check.ok).toBe(false); + expect(check.detail).toContain(dbPath); + }); + + it("doctor reports absent Docker gracefully (informational, always ok)", () => { + const check = checkDockerPresent({ resolveDockerPath: () => null }); + expect(check.ok).toBe(true); + expect(check.detail).toContain("optional"); + }); + + it("doctor reports Docker when which finds it", () => { + const check = checkDockerPresent({ resolveDockerPath: () => "/usr/bin/docker" }); + expect(check.ok).toBe(true); + expect(check.detail).toContain("/usr/bin/docker"); + }); + + it("runInit notes when sqlite already existed", () => { + const root = tempRoot(); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; + initLaptopState(env); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect(runInit([], env)).toBe(0); + expect(String(log.mock.calls[1]?.[0])).toContain("already existed"); + }); + + it("makes no network calls", () => { + const fetchStub = vi.fn(() => { + throw new Error("network calls are forbidden"); + }); + vi.stubGlobal("fetch", fetchStub); + const root = tempRoot(); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(root, "state") }; + vi.spyOn(console, "log").mockImplementation(() => {}); + runInit([], env); + checkDockerPresent(); + expect(fetchStub).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts index 276841d411..f7537cbebc 100644 --- a/test/unit/miner-status.test.ts +++ b/test/unit/miner-status.test.ts @@ -9,6 +9,7 @@ import { runDoctorChecks, runStatus, } from "../../packages/gittensory-miner/lib/status.js"; +import { initLaptopState } from "../../packages/gittensory-miner/lib/laptop-init.js"; const roots: string[] = []; @@ -51,12 +52,20 @@ describe("gittensory-miner status/doctor (#2288)", () => { 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)", () => { + it("doctor passes on a healthy setup (writable state dir, initialized sqlite, optional Docker)", () => { const log = vi.spyOn(console, "log").mockImplementation(() => {}); - const checks = runDoctorChecks({ GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }; + initLaptopState(env); + const checks = runDoctorChecks(env); 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(checks.map((check) => check.name)).toEqual([ + "node-version", + "engine-resolves", + "state-dir-writable", + "laptop-state-sqlite", + "docker-present", + ]); + expect(runDoctor([], env)).toBe(0); expect(log).toHaveBeenCalled(); }); @@ -79,8 +88,10 @@ describe("gittensory-miner status/doctor (#2288)", () => { }); 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") }); + const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") }; + initLaptopState(env); + runStatus(["--json"], env, tempRoot()); + runDoctor([], env); expect(fetchStub).not.toHaveBeenCalled(); }); });