diff --git a/package-lock.json b/package-lock.json index 5296f75ee2..9bdc25417e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15537,7 +15537,7 @@ "gittensory-miner": "bin/gittensory-miner.js" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.5.0" } } } diff --git a/packages/gittensory-miner/README.md b/packages/gittensory-miner/README.md index dd7b102c51..453676eaad 100644 --- a/packages/gittensory-miner/README.md +++ b/packages/gittensory-miner/README.md @@ -11,9 +11,10 @@ Current scope is intentionally small: - workspace package wiring - CLI entry point - `--help` and `version` commands +- laptop-mode `init` and `doctor` commands - startup npm version nudge (override with `--no-update-check` or `GITTENSORY_MINER_NO_UPDATE_CHECK=1`) -Real miner commands land in follow-up issues. +Discovery, planning, create, and manage commands land in follow-up issues. The package also includes the first metadata-only discovery primitive: `fetchCandidateIssues` lists open issue metadata across target repos, and `searchCandidateIssues` does the same from a GitHub issue-search query. Both @@ -36,8 +37,38 @@ gittensory-miner --help gittensory-miner help gittensory-miner --version gittensory-miner version +gittensory-miner init +gittensory-miner doctor ``` +## Laptop Mode + +Laptop mode is the zero-infra install-and-run path for local miner state. It uses a local SQLite state file and does not require Docker, Redis, or Postgres. + +Laptop mode requires Node.js 22.5.0 or newer because it uses Node's built-in SQLite module. + +```sh +npm install -g @jsonbored/gittensory-miner@latest +gittensory-miner init +gittensory-miner doctor +``` + +`gittensory-miner init` creates the config directory and initializes the SQLite state database. By default the config directory is: + +```sh +~/.config/gittensory-miner +``` + +Path resolution follows this order: + +1. `GITTENSORY_MINER_CONFIG_DIR` +2. `XDG_CONFIG_HOME/gittensory-miner` +3. `~/.config/gittensory-miner` + +The state database is stored at `state.sqlite3` inside the config directory. Re-running `init` is safe and does not delete existing state. + +`gittensory-miner doctor` reports the Node version, config directory, SQLite state path, state file existence/writability, and Docker presence. Docker is informational only and is never required for laptop mode. + ## Version check On every invocation the CLI starts an async npm registry lookup (5s timeout). When the installed package is behind `@jsonbored/gittensory-miner@latest`, it prints a one-line upgrade command to stderr without blocking or failing the requested command. Set `GITTENSORY_NPM_REGISTRY_URL` to point at a mirror, same as `@jsonbored/gittensory-mcp`. diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 414d619ca2..09b1e30578 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -41,6 +41,6 @@ if ( process.exit(0); } -const exitCode = runCli(cliArgs, { packageName }); +const exitCode = await runCli(cliArgs, { packageName, env: process.env }); await awaitOpportunisticUpdateCheck(updateCheck); process.exit(exitCode); diff --git a/packages/gittensory-miner/lib/cli.d.ts b/packages/gittensory-miner/lib/cli.d.ts index 30a181d860..d28d0424cc 100644 --- a/packages/gittensory-miner/lib/cli.d.ts +++ b/packages/gittensory-miner/lib/cli.d.ts @@ -1,3 +1,14 @@ +import type { MinerEnv, SpawnSyncLike } from "./laptop-init.js"; + export function printVersion(input: { packageName: string; packageVersion: string }): void; export function printHelp(input: { packageName: string }): void; -export function runCli(cliArgs: string[], input: { packageName: string }): number; +export function runCli( + cliArgs: string[], + input: { + packageName: string; + env?: MinerEnv; + homeDir?: string; + nodeVersion?: string; + spawnSync?: SpawnSyncLike; + }, +): Promise; diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 91ccc42dee..5f30779a1c 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -1,3 +1,14 @@ +import { + formatInitResult, + formatLaptopDoctor, + initLaptopMode, + inspectLaptopDoctor, +} from "./laptop-init.js"; + +function formatCliError(error) { + return error instanceof Error ? error.message : String(error); +} + export function printVersion(input) { console.log(`${input.packageName}/${input.packageVersion} (node ${process.version})`); } @@ -14,6 +25,8 @@ export function printHelp(input) { " gittensory-miner --version", " gittensory-miner help", " gittensory-miner version", + " gittensory-miner init", + " gittensory-miner doctor", "", "Options:", " --no-update-check Skip the npm registry version nudge (also GITTENSORY_MINER_NO_UPDATE_CHECK=1)", @@ -21,8 +34,34 @@ export function printHelp(input) { ); } -export function runCli(cliArgs, input) { +export async function runCli(cliArgs, input) { const command = cliArgs[0] ?? ""; + + if (command === "init") { + try { + const result = await initLaptopMode({ + env: input.env, + homeDir: input.homeDir, + }); + console.log(formatInitResult(result)); + return 0; + } catch (error) { + console.error(`init failed: ${formatCliError(error)}`); + return 1; + } + } + + if (command === "doctor") { + const report = await inspectLaptopDoctor({ + env: input.env, + homeDir: input.homeDir, + nodeVersion: input.nodeVersion, + spawnSync: input.spawnSync, + }); + console.log(formatLaptopDoctor(report)); + return 0; + } + console.error(`Unknown command: ${command}. Run ${input.packageName} --help.`); return 1; } 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..c9f4c1c58d --- /dev/null +++ b/packages/gittensory-miner/lib/laptop-init.d.ts @@ -0,0 +1,62 @@ +import type { SpawnSyncReturns } from "node:child_process"; + +export type MinerEnv = Readonly>>; + +export type SpawnSyncLike = ( + command: string, + args: readonly string[], + options: { encoding: "utf8"; timeout: number }, +) => SpawnSyncReturns; + +export interface LaptopPathOptions { + homeDir?: string; +} + +export interface LaptopPaths { + configDir: string; + statePath: string; +} + +export interface LaptopInitOptions extends LaptopPathOptions { + env?: MinerEnv; +} + +export interface LaptopInitResult extends LaptopPaths { + createdConfigDir: boolean; + createdStateFile: boolean; +} + +export interface LaptopDoctorOptions extends LaptopInitOptions { + nodeVersion?: string; + spawnSync?: SpawnSyncLike; +} + +export interface LaptopPathReport { + path: string; + exists: boolean; + isDirectory: boolean; + isFile: boolean; + writable: boolean; + error?: string; +} + +export interface LaptopDockerReport { + present: boolean; + status: "present" | "absent" | "unavailable"; + detail: string; +} + +export interface LaptopDoctorReport { + nodeVersion: string; + configDir: LaptopPathReport; + stateFile: LaptopPathReport; + docker: LaptopDockerReport; +} + +export function resolveLaptopPaths(env?: MinerEnv, options?: LaptopPathOptions): LaptopPaths; +export function ensureLaptopStateDatabase(statePath: string): Promise; +export function initLaptopMode(options?: LaptopInitOptions): Promise; +export function inspectLaptopDoctor(options?: LaptopDoctorOptions): Promise; +export function formatInitResult(result: LaptopInitResult): string; +export function formatLaptopDoctor(report: LaptopDoctorReport): string; + diff --git a/packages/gittensory-miner/lib/laptop-init.js b/packages/gittensory-miner/lib/laptop-init.js new file mode 100644 index 0000000000..2f3ad46ce7 --- /dev/null +++ b/packages/gittensory-miner/lib/laptop-init.js @@ -0,0 +1,212 @@ +import { spawnSync as defaultSpawnSync } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; +import { access, mkdir, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const CONFIG_DIR_ENV = "GITTENSORY_MINER_CONFIG_DIR"; +const STATE_FILE_NAME = "state.sqlite3"; + +function cleanEnvPath(value) { + return typeof value === "string" && value.trim().length > 0 + ? value + : undefined; +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +async function pathExists(path) { + try { + await stat(path); + return true; + } catch (error) { + if (error?.code === "ENOENT") { + return false; + } + throw error; + } +} + +async function isWritable(path) { + try { + await access(path, fsConstants.W_OK); + return true; + } catch { + return false; + } +} + +async function inspectPath(path) { + try { + const stats = await stat(path); + return { + exists: true, + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + writable: await isWritable(path), + }; + } catch (error) { + if (error?.code === "ENOENT") { + return { + exists: false, + isDirectory: false, + isFile: false, + writable: false, + }; + } + return { + exists: false, + isDirectory: false, + isFile: false, + writable: false, + error: errorMessage(error), + }; + } +} + +export function resolveLaptopPaths(env = process.env, options = {}) { + const home = options.homeDir ?? homedir(); + const xdgConfigHome = cleanEnvPath(env.XDG_CONFIG_HOME) ?? join(home, ".config"); + const configDir = + cleanEnvPath(env[CONFIG_DIR_ENV]) ?? join(xdgConfigHome, "gittensory-miner"); + + return { + configDir, + statePath: join(configDir, STATE_FILE_NAME), + }; +} + +export async function ensureLaptopStateDatabase(statePath) { + const { DatabaseSync } = await import("node:sqlite"); + let db; + + try { + db = new DatabaseSync(statePath); + db.exec(` + PRAGMA user_version = 1; + CREATE TABLE IF NOT EXISTS miner_state_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + INSERT INTO miner_state_meta (key, value) + VALUES ('schema_version', '1') + ON CONFLICT(key) DO UPDATE SET value = excluded.value; + `); + } catch (error) { + throw new Error( + `Unable to initialize SQLite state at ${statePath}: ${errorMessage(error)}`, + ); + } finally { + db?.close(); + } +} + +export async function initLaptopMode(options = {}) { + const paths = resolveLaptopPaths(options.env ?? process.env, { + homeDir: options.homeDir, + }); + const configDirExisted = await pathExists(paths.configDir); + await mkdir(paths.configDir, { recursive: true }); + const stateFileExisted = await pathExists(paths.statePath); + + await ensureLaptopStateDatabase(paths.statePath); + + return { + ...paths, + createdConfigDir: !configDirExisted, + createdStateFile: !stateFileExisted, + }; +} + +function inspectDocker(spawnSync = defaultSpawnSync) { + try { + const result = spawnSync("docker", ["--version"], { + encoding: "utf8", + timeout: 2_000, + }); + + if (result.error) { + return { + present: false, + status: "absent", + detail: result.error.code ?? result.error.message, + }; + } + + if (result.status === 0) { + const version = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); + return { + present: true, + status: "present", + detail: version || "docker command returned successfully", + }; + } + + return { + present: false, + status: "unavailable", + detail: + `${result.stderr ?? ""}${result.stdout ?? ""}`.trim() || + `docker exited with status ${result.status}`, + }; + } catch (error) { + return { + present: false, + status: "absent", + detail: errorMessage(error), + }; + } +} + +export async function inspectLaptopDoctor(options = {}) { + const paths = resolveLaptopPaths(options.env ?? process.env, { + homeDir: options.homeDir, + }); + const configDir = await inspectPath(paths.configDir); + const stateFile = await inspectPath(paths.statePath); + + return { + nodeVersion: options.nodeVersion ?? process.version, + configDir: { + path: paths.configDir, + ...configDir, + }, + stateFile: { + path: paths.statePath, + ...stateFile, + writable: stateFile.writable && configDir.writable, + }, + docker: inspectDocker(options.spawnSync), + }; +} + +export function formatInitResult(result) { + return [ + "gittensory-miner laptop mode initialized.", + `config dir: ${result.configDir}`, + `state file: ${result.statePath}`, + `config dir status: ${result.createdConfigDir ? "created" : "already exists"}`, + `state file status: ${result.createdStateFile ? "created" : "already exists"}`, + ].join("\n"); +} + +export function formatLaptopDoctor(report) { + const dockerStatus = report.docker.present + ? `present (${report.docker.detail})` + : report.docker.status === "unavailable" + ? `unavailable (${report.docker.detail}; informational only)` + : `not found (${report.docker.detail}; informational only)`; + + return [ + `node: ${report.nodeVersion}`, + `config dir: ${report.configDir.path}`, + `config dir exists: ${report.configDir.exists ? "yes" : "no"}`, + `config dir writable: ${report.configDir.writable ? "yes" : "no"}`, + `sqlite state: ${report.stateFile.path}`, + `sqlite state exists: ${report.stateFile.exists ? "yes" : "no"}`, + `sqlite state writable: ${report.stateFile.writable ? "yes" : "no"}`, + `docker: ${dockerStatus}`, + ].join("\n"); +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 54edb7b0da..42c26c5825 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -31,12 +31,12 @@ "lib" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/laptop-init.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.5.0" } } diff --git a/test/unit/miner-cli.test.ts b/test/unit/miner-cli.test.ts index d502bac0c3..2160f78a40 100644 --- a/test/unit/miner-cli.test.ts +++ b/test/unit/miner-cli.test.ts @@ -1,4 +1,7 @@ import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { bin, @@ -8,12 +11,20 @@ import { } from "./support/miner-cli-harness"; type MinerCli = typeof import("../../packages/gittensory-miner/lib/cli.js"); +type MinerLaptop = + typeof import("../../packages/gittensory-miner/lib/laptop-init.js"); type MinerUpdateCheck = typeof import("../../packages/gittensory-miner/lib/update-check.js"); +const tempRoots: string[] = []; + let printHelp: MinerCli["printHelp"]; let printVersion: MinerCli["printVersion"]; let runCli: MinerCli["runCli"]; +let formatLaptopDoctor: MinerLaptop["formatLaptopDoctor"]; +let initLaptopMode: MinerLaptop["initLaptopMode"]; +let inspectLaptopDoctor: MinerLaptop["inspectLaptopDoctor"]; +let resolveLaptopPaths: MinerLaptop["resolveLaptopPaths"]; let compareSemver: MinerUpdateCheck["compareSemver"]; let fetchLatestPackageVersion: MinerUpdateCheck["fetchLatestPackageVersion"]; let maybePrintUpdateNudge: MinerUpdateCheck["maybePrintUpdateNudge"]; @@ -25,9 +36,17 @@ let awaitOpportunisticUpdateCheck: MinerUpdateCheck["awaitOpportunisticUpdateChe beforeAll(async () => { const cli = await import("../../packages/gittensory-miner/lib/cli.js"); + const laptop = + await import("../../packages/gittensory-miner/lib/laptop-init.js"); const updateCheck = await import("../../packages/gittensory-miner/lib/update-check.js"); ({ printHelp, printVersion, runCli } = cli); + ({ + formatLaptopDoctor, + initLaptopMode, + inspectLaptopDoctor, + resolveLaptopPaths, + } = laptop); ({ compareSemver, fetchLatestPackageVersion, @@ -43,8 +62,33 @@ beforeAll(async () => { afterEach(async () => { vi.restoreAllMocks(); await closeFixtureServer(); + await Promise.all( + tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); }); +async function makeTempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "gittensory-miner-")); + tempRoots.push(root); + return root; +} + +async function readMinerSchemaVersion(statePath: string): Promise { + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(statePath); + try { + const row = db + .prepare("SELECT value FROM miner_state_meta WHERE key = ?") + .get("schema_version"); + if (!row) { + throw new Error("missing miner_state_meta schema_version row"); + } + return row.value as string; + } finally { + db.close(); + } +} + describe("gittensory-miner CLI helpers", () => { it("prints the package version with the node runtime", () => { const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -64,16 +108,18 @@ describe("gittensory-miner CLI helpers", () => { const text = log.mock.calls[0]?.[0]; expect(text).toContain("gittensory-miner --help"); expect(text).toContain("gittensory-miner version"); + expect(text).toContain("gittensory-miner init"); + expect(text).toContain("gittensory-miner doctor"); expect(text).toContain("--no-update-check"); }); - it("returns exit code 1 for unknown commands", () => { + it("returns exit code 1 for unknown commands", async () => { const error = vi .spyOn(console, "error") .mockImplementation(() => undefined); - expect( + await expect( runCli(["mystery"], { packageName: "@jsonbored/gittensory-miner" }), - ).toBe(1); + ).resolves.toBe(1); expect(error).toHaveBeenCalledWith( "Unknown command: mystery. Run @jsonbored/gittensory-miner --help.", ); @@ -85,6 +131,204 @@ describe("gittensory-miner CLI helpers", () => { { with: { type: "json" } } ); expect(packageJson.default.version).toBe("0.1.0"); + expect(packageJson.default.engines.node).toBe(">=22.5.0"); + }); +}); + +describe("gittensory-miner laptop mode (#2329)", () => { + it("resolves config paths from miner env, XDG, then home fallback", async () => { + const root = await makeTempRoot(); + const explicitConfig = join(root, "explicit"); + const xdgConfigHome = join(root, "xdg"); + const homeDir = join(root, "home"); + + expect( + resolveLaptopPaths( + { + GITTENSORY_MINER_CONFIG_DIR: explicitConfig, + XDG_CONFIG_HOME: xdgConfigHome, + }, + { homeDir }, + ), + ).toEqual({ + configDir: explicitConfig, + statePath: join(explicitConfig, "state.sqlite3"), + }); + expect(resolveLaptopPaths({ XDG_CONFIG_HOME: xdgConfigHome }, { homeDir })) + .toEqual({ + configDir: join(xdgConfigHome, "gittensory-miner"), + statePath: join(xdgConfigHome, "gittensory-miner", "state.sqlite3"), + }); + expect(resolveLaptopPaths({}, { homeDir })).toEqual({ + configDir: join(homeDir, ".config", "gittensory-miner"), + statePath: join(homeDir, ".config", "gittensory-miner", "state.sqlite3"), + }); + }); + + it("creates the config directory and SQLite state database on fresh init", async () => { + const root = await makeTempRoot(); + const configDir = join(root, "config"); + const result = await initLaptopMode({ + env: { GITTENSORY_MINER_CONFIG_DIR: configDir }, + }); + + expect(result).toEqual({ + configDir, + statePath: join(configDir, "state.sqlite3"), + createdConfigDir: true, + createdStateFile: true, + }); + await expect(readFile(result.statePath)).resolves.toEqual( + expect.objectContaining({ + length: expect.any(Number), + }), + ); + expect((await readFile(result.statePath)).subarray(0, 16).toString()).toBe( + "SQLite format 3\u0000", + ); + await expect(readMinerSchemaVersion(result.statePath)).resolves.toBe("1"); + }); + + it("reruns init without clobbering existing SQLite state", async () => { + const root = await makeTempRoot(); + const configDir = join(root, "config"); + const first = await initLaptopMode({ + env: { GITTENSORY_MINER_CONFIG_DIR: configDir }, + }); + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(first.statePath); + try { + db.exec( + "CREATE TABLE retained_state (value TEXT NOT NULL); INSERT INTO retained_state (value) VALUES ('keep');", + ); + } finally { + db.close(); + } + + const second = await initLaptopMode({ + env: { GITTENSORY_MINER_CONFIG_DIR: configDir }, + }); + const verifyDb = new DatabaseSync(second.statePath); + try { + expect(second.createdConfigDir).toBe(false); + expect(second.createdStateFile).toBe(false); + const retained = verifyDb.prepare("SELECT value FROM retained_state").get(); + const schema = verifyDb + .prepare("SELECT value FROM miner_state_meta WHERE key = ?") + .get("schema_version"); + if (!retained || !schema) { + throw new Error("missing retained state or schema metadata row"); + } + expect(retained.value).toBe("keep"); + expect(schema.value).toBe("1"); + } finally { + verifyDb.close(); + } + }); + + it("initializes an existing empty state file instead of treating it as ready", async () => { + const root = await makeTempRoot(); + const configDir = join(root, "config"); + const statePath = join(configDir, "state.sqlite3"); + await mkdir(configDir, { recursive: true }); + await writeFile(statePath, ""); + + const result = await initLaptopMode({ + env: { GITTENSORY_MINER_CONFIG_DIR: configDir }, + }); + + expect(result.createdConfigDir).toBe(false); + expect(result.createdStateFile).toBe(false); + expect((await readFile(result.statePath)).subarray(0, 16).toString()).toBe( + "SQLite format 3\u0000", + ); + await expect(readMinerSchemaVersion(result.statePath)).resolves.toBe("1"); + }); + + it("doctor reports absent Docker gracefully", async () => { + const root = await makeTempRoot(); + const configDir = join(root, "config"); + await initLaptopMode({ + env: { GITTENSORY_MINER_CONFIG_DIR: configDir }, + }); + + const report = await inspectLaptopDoctor({ + env: { GITTENSORY_MINER_CONFIG_DIR: configDir }, + nodeVersion: "v22.0.0", + spawnSync: () => ({ + pid: 0, + output: [null, "", ""], + stdout: "", + stderr: "", + status: null, + signal: null, + error: Object.assign(new Error("spawn docker ENOENT"), { + code: "ENOENT", + }), + }), + }); + const output = formatLaptopDoctor(report); + + expect(report.nodeVersion).toBe("v22.0.0"); + expect(report.configDir).toMatchObject({ + path: configDir, + exists: true, + isDirectory: true, + writable: true, + }); + expect(report.stateFile).toMatchObject({ + path: join(configDir, "state.sqlite3"), + exists: true, + isFile: true, + writable: true, + }); + expect(report.docker).toEqual({ + present: false, + status: "absent", + detail: "ENOENT", + }); + expect(output).toContain("docker: not found (ENOENT; informational only)"); + }); + + it("runs laptop init and doctor through the command dispatcher", async () => { + const root = await makeTempRoot(); + const configDir = join(root, "config"); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const env = { GITTENSORY_MINER_CONFIG_DIR: configDir }; + const spawnSyncMissingDocker = () => ({ + pid: 0, + output: [null, "", ""], + stdout: "", + stderr: "", + status: null, + signal: null, + error: Object.assign(new Error("spawn docker ENOENT"), { + code: "ENOENT", + }), + }); + + await expect( + runCli(["init"], { + packageName: "@jsonbored/gittensory-miner", + env, + }), + ).resolves.toBe(0); + await expect(readMinerSchemaVersion(join(configDir, "state.sqlite3"))) + .resolves.toBe("1"); + + await expect( + runCli(["doctor"], { + packageName: "@jsonbored/gittensory-miner", + env, + spawnSync: spawnSyncMissingDocker, + }), + ).resolves.toBe(0); + expect(log.mock.calls[0]?.[0]).toContain( + "gittensory-miner laptop mode initialized.", + ); + expect(log.mock.calls[1]?.[0]).toContain( + "docker: not found (ENOENT; informational only)", + ); }); });