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
16 changes: 16 additions & 0 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 8 additions & 3 deletions packages/gittensory-miner/bin/gittensory-miner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)));
}
Expand Down
1 change: 1 addition & 0 deletions packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions packages/gittensory-miner/lib/laptop-init.d.ts
Original file line number Diff line number Diff line change
@@ -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, string | undefined>): string;

export function initLaptopState(env?: Record<string, string | undefined>): LaptopInitResult;

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

export function checkDockerPresent(options?: {
resolveDockerPath?: () => string | null;
}): DoctorCheck;

export function runInit(args?: string[], env?: Record<string, string | undefined>): number;
101 changes: 101 additions & 0 deletions packages/gittensory-miner/lib/laptop-init.js
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions packages/gittensory-miner/lib/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
];
}

Expand Down
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/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"
Expand Down
121 changes: 121 additions & 0 deletions test/unit/miner-laptop-init.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
23 changes: 17 additions & 6 deletions test/unit/miner-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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();
});

Expand All @@ -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();
});
});
Loading