Skip to content

Commit 77ee54d

Browse files
fix(miner): harden laptop-mode doctor checks (#2329)
1 parent 50cba3a commit 77ee54d

3 files changed

Lines changed: 86 additions & 9 deletions

File tree

packages/gittensory-miner/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ gittensory-miner doctor
4848

4949
`init` bootstraps the local config directory and the SQLite-backed `run-state.sqlite3` file. Path
5050
resolution mirrors the package's other local stores: `GITTENSORY_MINER_CONFIG_DIR`, then
51-
`XDG_CONFIG_HOME`, then `~/.config/gittensory-miner/`.
51+
`XDG_CONFIG_HOME`, then `~/.config/gittensory-miner/`. If you need the SQLite file elsewhere, set
52+
`GITTENSORY_MINER_RUN_STATE_DB`; that overrides the DB path only, not the config-dir chain.
5253

5354
## Commands
5455

@@ -61,8 +62,8 @@ gittensory-miner init
6162
gittensory-miner doctor
6263
```
6364

64-
`doctor` always reports Docker as informational only. Laptop mode never requires Docker, Redis, or
65-
Postgres to initialize.
65+
`doctor` always reports Docker as informational only and times out quickly if `docker --version`
66+
hangs. Laptop mode never requires Docker, Redis, or Postgres to initialize.
6667

6768
## Version check
6869

packages/gittensory-miner/lib/laptop-mode.js

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { spawnSync } from "node:child_process";
22
import { accessSync, constants, existsSync } from "node:fs";
3-
import { dirname } from "node:path";
3+
import { homedir } from "node:os";
4+
import { dirname, join } from "node:path";
45
import { DatabaseSync } from "node:sqlite";
56
import { initRunStateStore, resolveRunStateDbPath } from "./run-state.js";
67

8+
const dockerProbeTimeoutMs = 1500;
9+
710
function errorDetail(error) {
811
return error instanceof Error && error.message ? error.message : "unknown_error";
912
}
@@ -13,7 +16,15 @@ export function resolveLaptopModeStateDbPath(env = process.env) {
1316
}
1417

1518
export function resolveLaptopModeConfigDir(env = process.env) {
16-
return dirname(resolveLaptopModeStateDbPath(env));
19+
const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string"
20+
? env.GITTENSORY_MINER_CONFIG_DIR.trim()
21+
: "";
22+
if (explicitConfigDir) return explicitConfigDir;
23+
24+
const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()
25+
? env.XDG_CONFIG_HOME.trim()
26+
: join(homedir(), ".config");
27+
return join(configHome, "gittensory-miner");
1728
}
1829

1930
/**
@@ -41,10 +52,29 @@ export function initLaptopMode(input = {}) {
4152
}
4253
}
4354

55+
function nearestExistingAncestor(path) {
56+
let candidate = dirname(path);
57+
while (!existsSync(candidate)) {
58+
const parent = dirname(candidate);
59+
if (parent === candidate) return null;
60+
candidate = parent;
61+
}
62+
return candidate;
63+
}
64+
4465
function inspectWritablePath(path) {
4566
const exists = existsSync(path);
4667
if (!exists) {
47-
return { path, exists: false, writable: false, error: null };
68+
const ancestor = nearestExistingAncestor(path);
69+
if (!ancestor) {
70+
return { path, exists: false, writable: false, error: "missing_parent_directory" };
71+
}
72+
try {
73+
accessSync(ancestor, constants.W_OK | constants.X_OK);
74+
return { path, exists: false, writable: true, error: null };
75+
} catch (error) {
76+
return { path, exists: false, writable: false, error: errorDetail(error) };
77+
}
4878
}
4979
try {
5080
accessSync(path, constants.W_OK);
@@ -96,12 +126,19 @@ function probeDocker(spawn = spawnSync) {
96126
result = spawn("docker", ["--version"], {
97127
encoding: "utf8",
98128
stdio: ["ignore", "pipe", "pipe"],
129+
timeout: dockerProbeTimeoutMs,
99130
});
100131
} catch (error) {
101132
return { available: false, detail: errorDetail(error) };
102133
}
103134

104135
if (result.error) {
136+
if (result.error.code === "ETIMEDOUT") {
137+
return {
138+
available: false,
139+
detail: `docker --version timed out after ${dockerProbeTimeoutMs}ms`,
140+
};
141+
}
105142
return {
106143
available: false,
107144
detail: result.error.code === "ENOENT" ? "docker not found on PATH" : errorDetail(result.error),

test/unit/miner-laptop-mode.test.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,21 @@ describe("gittensory-miner laptop mode bootstrap (#2329)", () => {
3838
expect(resolveLaptopModeStateDbPath({})).toMatch(/\/\.config\/gittensory-miner\/run-state\.sqlite3$/);
3939
});
4040

41+
it("keeps the laptop-mode config dir on the config chain even when the run-state DB path is overridden", () => {
42+
const env = {
43+
GITTENSORY_MINER_RUN_STATE_DB: "/custom/state.sqlite3",
44+
GITTENSORY_MINER_CONFIG_DIR: "/custom/config",
45+
XDG_CONFIG_HOME: "/xdg",
46+
};
47+
48+
expect(resolveLaptopModeConfigDir(env)).toBe("/custom/config");
49+
expect(resolveLaptopModeStateDbPath(env)).toBe("/custom/state.sqlite3");
50+
expect(resolveLaptopModeConfigDir({
51+
GITTENSORY_MINER_RUN_STATE_DB: "/custom/state.sqlite3",
52+
XDG_CONFIG_HOME: "/xdg",
53+
})).toBe("/xdg/gittensory-miner");
54+
});
55+
4156
it("initializes the config dir and SQLite run-state store on a fresh laptop-mode boot", () => {
4257
const configDir = join(tempRoot(), "config");
4358
const result = initLaptopMode({ env: { GITTENSORY_MINER_CONFIG_DIR: configDir } });
@@ -111,9 +126,8 @@ describe("gittensory-miner laptop mode bootstrap (#2329)", () => {
111126
expect(formatLaptopDoctor(report)).toContain("miner_run_state ready");
112127
});
113128

114-
it("doctor handles missing Docker and a missing state DB gracefully", () => {
129+
it("doctor treats a fresh, creatable config dir as writable and handles missing Docker gracefully", () => {
115130
const configDir = join(tempRoot(), "config");
116-
mkdirSync(configDir, { recursive: true });
117131

118132
const report = inspectLaptopMode({
119133
env: { GITTENSORY_MINER_CONFIG_DIR: configDir },
@@ -125,10 +139,16 @@ describe("gittensory-miner laptop mode bootstrap (#2329)", () => {
125139
})) as never,
126140
});
127141

142+
expect(report.configDir).toMatchObject({
143+
path: configDir,
144+
exists: false,
145+
writable: true,
146+
error: null,
147+
});
128148
expect(report.stateDb).toMatchObject({
129149
path: join(configDir, "run-state.sqlite3"),
130150
exists: false,
131-
writable: false,
151+
writable: true,
132152
sqliteReady: false,
133153
schemaReady: false,
134154
schemaError: null,
@@ -137,6 +157,25 @@ describe("gittensory-miner laptop mode bootstrap (#2329)", () => {
137157
expect(formatLaptopDoctor(report)).toContain("Docker: unavailable (docker not found on PATH; informational only)");
138158
});
139159

160+
it("doctor treats a timed-out Docker probe as informational only", () => {
161+
const configDir = join(tempRoot(), "config");
162+
initLaptopMode({ env: { GITTENSORY_MINER_CONFIG_DIR: configDir } });
163+
164+
const report = inspectLaptopMode({
165+
env: { GITTENSORY_MINER_CONFIG_DIR: configDir },
166+
spawnSyncFn: vi.fn(() => ({
167+
status: null,
168+
stdout: "",
169+
stderr: "",
170+
error: Object.assign(new Error("spawn docker ETIMEDOUT"), { code: "ETIMEDOUT" }),
171+
})) as never,
172+
});
173+
174+
expect(report.docker.available).toBe(false);
175+
expect(report.docker.detail).toContain("timed out");
176+
expect(formatLaptopDoctor(report)).toContain("informational only");
177+
});
178+
140179
it("doctor surfaces an invalid existing SQLite file with the schema error detail in the human output", () => {
141180
const configDir = join(tempRoot(), "config");
142181
mkdirSync(configDir, { recursive: true });

0 commit comments

Comments
 (0)