Skip to content
Merged
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
207 changes: 140 additions & 67 deletions src/lib/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,115 +3,188 @@ import { OPENAPI_SPEC_URL } from "~/lib/constants.js";
import { discoverAppPath, readRuntimeState, serverStatus } from "~/lib/local-server.js";
import { ping } from "~/lib/bluebubbles/ping.js";
import { BlueBubblesClient } from "~/lib/bluebubbles/client.js";
import { getServerInfo } from "~/lib/bluebubbles/server.js";
import type { CliConfig, DoctorCheck } from "~/lib/types.js";

export async function runDoctor(input: {
type DoctorInput = {
config: CliConfig;
configPath: string;
statePath: string;
}): Promise<DoctorCheck[]> {
const checks: DoctorCheck[] = [];
};

function checkConfigFile(input: DoctorInput): DoctorCheck {
const hasRuntimeConfig = input.config.baseUrl && input.config.password;
checks.push({
if (existsSync(input.configPath)) {
return { name: "config file", status: "pass", detail: `Found ${input.configPath}` };
}
return {
name: "config file",
status: existsSync(input.configPath) ? "pass" : (hasRuntimeConfig ? "pass" : "warn"),
detail: existsSync(input.configPath)
? `Found ${input.configPath}`
: (hasRuntimeConfig ? "Using runtime config (env and/or CLI flags)." : `No config file yet at ${input.configPath}`),
});
status: hasRuntimeConfig ? "pass" : "warn",
detail: hasRuntimeConfig
? "Using runtime config (env and/or CLI flags)."
: `No config file yet at ${input.configPath}`,
};
}

checks.push({
function checkBaseUrl(input: DoctorInput): DoctorCheck {
return {
name: "base URL",
status: input.config.baseUrl ? "pass" : "warn",
detail: input.config.baseUrl ?? "Missing BLUEBUBBLES_BASE_URL or persisted baseUrl",
});
};
}

checks.push({
function checkPassword(input: DoctorInput): DoctorCheck {
return {
name: "password",
status: input.config.password ? "pass" : "warn",
detail: input.config.password ? "Configured" : "Missing BLUEBUBBLES_PASSWORD or persisted password",
});
};
}

function checkLocalApp(input: DoctorInput): DoctorCheck {
const appPath = discoverAppPath(input.config);
checks.push({
if (process.platform !== "darwin") {
return { name: "local app", status: "warn", detail: "Local lifecycle commands require macOS" };
}
return {
name: "local app",
status: process.platform === "darwin" ? (appPath ? "pass" : "warn") : "warn",
detail:
process.platform === "darwin"
? appPath ?? "No installed BlueBubbles app found in the default locations"
: "Local lifecycle commands require macOS",
});

const status = await serverStatus({ statePath: input.statePath, config: input.config });
let apiPingPass = false;
status: appPath ? "pass" : "warn",
detail: appPath ?? "No installed BlueBubbles app found in the default locations",
};
}

if (input.config.baseUrl && input.config.password) {
try {
const client = new BlueBubblesClient({
baseUrl: input.config.baseUrl,
password: input.config.password,
});
await ping(client);
apiPingPass = true;
} catch {
apiPingPass = false;
}
async function tryApiPing(input: DoctorInput): Promise<boolean> {
if (!input.config.baseUrl || !input.config.password) return false;
try {
const client = new BlueBubblesClient({
baseUrl: input.config.baseUrl,
password: input.config.password,
});
await ping(client);
return true;
} catch {
return false;
}
}

checks.push({
async function checkLocalProcess(input: DoctorInput, apiPingPass: boolean): Promise<DoctorCheck> {
const status = await serverStatus({ statePath: input.statePath, config: input.config });
return {
name: "local process",
status: (status.running || apiPingPass) ? "pass" : "warn",
detail: status.running
? `Running with PID ${status.state!.pid}`
: (apiPingPass ? "Running (External)" : "Not running under CLI control"),
});
status: status.running || apiPingPass ? "pass" : "warn",
detail: status.running
? `Running with PID ${status.state!.pid}`
: apiPingPass
? "Running (External)"
: "Not running under CLI control",
};
}

if (input.config.baseUrl && input.config.password) {
if (apiPingPass) {
checks.push({
name: "api ping",
status: "pass",
detail: "BlueBubbles API responded successfully",
});
} else {
checks.push({
name: "api ping",
status: "fail",
detail: "Unable to reach the BlueBubbles API",
});
}
} else {
checks.push({
function checkApiPing(input: DoctorInput, apiPingPass: boolean): DoctorCheck {
if (!input.config.baseUrl || !input.config.password) {
return {
name: "api ping",
status: "warn",
detail: "Skipped because base URL or password is missing",
};
}
return apiPingPass
? { name: "api ping", status: "pass", detail: "BlueBubbles API responded successfully" }
: { name: "api ping", status: "fail", detail: "Unable to reach the BlueBubbles API" };
}

async function checkPrivateApiHelper(input: DoctorInput, apiPingPass: boolean): Promise<DoctorCheck> {
if (!apiPingPass || !input.config.baseUrl || !input.config.password) {
return {
name: "private API helper",
status: "warn",
detail: "Skipped because api ping did not succeed",
};
}
try {
const client = new BlueBubblesClient({
baseUrl: input.config.baseUrl,
password: input.config.password,
});
const info = (await getServerInfo(client)).data ?? {};
const privateApi = info.private_api === true;
const helperConnected = info.helper_connected === true;
Comment on lines +111 to +112
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat unknown helper flags as indeterminate, not disabled

/api/v1/server/info fields are optional in practice, but this logic coerces missing private_api/helper_connected keys into false and then reports "Private API is disabled". In environments where the server returns partial metadata (e.g., older server builds or stripped responses), users will get a misleading diagnostic even when the actual Private API state is unknown; this should instead emit an "unable to determine" warning unless both flags are explicitly present.

Useful? React with 👍 / 👎.

if (privateApi && helperConnected) {
return {
name: "private API helper",
status: "pass",
detail: "Server has Private API enabled and helper is connected",
};
}
if (privateApi && !helperConnected) {
return {
name: "private API helper",
status: "warn",
detail:
"Private API enabled in server settings but helper dylib is NOT connected — unsend/edit/tapback/typing-indicator endpoints will fail. Install/repair the Messages Helper via the BlueBubbles UI (Settings → Private API).",
};
}
return {
name: "private API helper",
status: "warn",
detail:
"Private API is disabled in server settings. Enable it (BlueBubbles UI → Settings → Private API) and install the Messages Helper to use unsend/edit/tapback/typing-indicator endpoints.",
};
} catch (error) {
return {
name: "private API helper",
status: "warn",
detail:
error instanceof Error
? `Failed to read /api/v1/server/info: ${error.message}`
: "Failed to read /api/v1/server/info",
};
}
}

async function checkOpenApiUrl(): Promise<DoctorCheck> {
try {
const response = await fetch(OPENAPI_SPEC_URL, { method: "HEAD" });
checks.push({
return {
name: "official OpenAPI URL",
status: response.ok ? "pass" : "warn",
detail: response.ok ? OPENAPI_SPEC_URL : `Returned status ${response.status}`,
});
};
} catch (error) {
checks.push({
return {
name: "official OpenAPI URL",
status: "warn",
detail: error instanceof Error ? error.message : "Unable to reach the official OpenAPI URL",
});
};
}
}

async function checkLogPath(input: DoctorInput): Promise<DoctorCheck | null> {
const state = await readRuntimeState(input.statePath);
if (state?.logPath) {
checks.push({
name: "log path",
status: existsSync(state.logPath) ? "pass" : "warn",
detail: existsSync(state.logPath) ? state.logPath : `Missing log file ${state.logPath}`,
});
}
if (!state?.logPath) return null;
return {
name: "log path",
status: existsSync(state.logPath) ? "pass" : "warn",
detail: existsSync(state.logPath) ? state.logPath : `Missing log file ${state.logPath}`,
};
}

export async function runDoctor(input: DoctorInput): Promise<DoctorCheck[]> {
const checks: DoctorCheck[] = [];
checks.push(checkConfigFile(input));
checks.push(checkBaseUrl(input));
checks.push(checkPassword(input));
checks.push(checkLocalApp(input));

const apiPingPass = await tryApiPing(input);
checks.push(await checkLocalProcess(input, apiPingPass));
checks.push(checkApiPing(input, apiPingPass));
checks.push(await checkPrivateApiHelper(input, apiPingPass));
checks.push(await checkOpenApiUrl());

const logPath = await checkLogPath(input);
if (logPath) checks.push(logPath);

return checks;
}
Loading