@wonderwhy-er/desktop-commander
Version:
MCP server for terminal operations and file editing
749 lines (748 loc) • 33.5 kB
JavaScript
import { exec, execFile } from 'child_process';
import dns from 'dns';
import fs from 'fs';
import net from 'net';
import { createRequire } from 'module';
import os from 'os';
import path from 'path';
import tls from 'tls';
import { fileURLToPath } from 'url';
import PizZip from 'pizzip';
import { parseConfig } from '../../config-manager.js';
import { VERSION } from '../../version.js';
import { deviceLogNames, getDeviceLogDir, cleanLine } from './device-log.js';
import { redact } from './redact.js';
import { savedUserId, uploadReport } from './upload.js';
/**
* `desktop-commander remote --report`: a diagnostics zip a user sends to
* support. It doesn't start the device and never opens sign-in, so it works
* when sign-in is broken. After saving, it uploads the zip (upload.ts) unless
* --no-upload, and prints the report id to give support.
*
* Only listed facts go in: versions (npm included), how Node runs (the Node
* executable and the entry script, home folder as ~), the clock skew from the
* server's Date header, network timings (server, Supabase REST, realtime
* websocket), the device id and yes/no facts about the rest of device.json,
* telemetryEnabled and clientId from config.json, and the device log
* re-filtered and re-masked. Paths and error texts pass through redact().
* Tokens, emails, the user name and tool arguments or results never go in.
*/
const DEFAULT_SERVER_URL = 'https://mcp.desktopcommander.app';
const CONNECT_TIMEOUT_MS = 4000;
const REQUEST_TIMEOUT_MS = 5000;
const MCP_INFO_ROUNDS = 5;
const NPM_TIMEOUT_MS = 5000;
const PROCESS_LIST_TIMEOUT_MS = 10000;
/** The npm package, and the MCPB bundle (named from manifest.json). */
const PACKAGE_NAMES = new Set(['@wonderwhy-er/desktop-commander', 'desktop-commander']);
/** `desktop-commander <these>` is not the MCP server. */
const NOT_THE_SERVER = new Set(['remote', 'setup', 'remove']);
/** A rotated log file is 1 MB at most; read no more than this of a larger one. */
const MAX_LOG_READ_BYTES = 2 * 1024 * 1024;
// --- small helpers ---------------------------------------------------------------
/** Short reasons for the network errors a user's machine typically gives. */
const NETWORK_REASONS = {
ECONNREFUSED: 'connection refused',
ECONNRESET: 'connection reset',
ETIMEDOUT: 'timed out',
UND_ERR_CONNECT_TIMEOUT: 'timed out',
ENOTFOUND: 'name not found',
EAI_AGAIN: 'name lookup failed',
EHOSTUNREACH: 'host unreachable',
ENETUNREACH: 'network unreachable',
};
function errorText(error) {
const err = error;
if (err?.name === 'TimeoutError' || err?.name === 'AbortError')
return 'timed out';
// fetch() wraps the socket error in `cause`
const code = String(err?.code ?? err?.cause?.code ?? '');
if (NETWORK_REASONS[code])
return NETWORK_REASONS[code];
if (/CERT|SELF_SIGNED/.test(code))
return `certificate problem (${code})`;
const cause = err?.cause?.message;
const message = String(err?.message ?? error);
const firstLine = (cause && !message.includes(cause) ? `${message} (${cause})` : message).split(/\r?\n/)[0];
return redact(firstLine).slice(0, 200);
}
function elapsed(startedAt) {
return Math.round(Number(process.hrtime.bigint() - startedAt) / 1e6);
}
function withTimeout(promise, ms) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timed out')), ms); }),
]).finally(() => clearTimeout(timer));
}
function median(values) {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}
function yesNo(value) {
return value ? 'yes' : 'no';
}
// --- versions -------------------------------------------------------------------------
function runKind() {
if (process.env.MCP_DXT)
return 'MCPB';
const here = fileURLToPath(import.meta.url);
const parts = here.split(/[\\/]/);
if (parts.includes('_npx'))
return 'npx';
// dist/remote-device/diagnostics/report.js -> the package root
const root = path.resolve(path.dirname(here), '..', '..', '..');
if (fs.existsSync(path.join(root, '.git')) || fs.existsSync(path.join(root, 'src', 'version.ts')))
return 'dev checkout';
if (parts.includes('node_modules'))
return 'global npm';
return 'unknown';
}
/**
* Which installer the Node executable comes from, read from its path. A path
* none of these match is "unknown": the report shows the path itself anyway.
*/
export function nodeKind(execPath) {
const p = execPath.replace(/\\/g, '/').toLowerCase();
if (/\/(\.nvm|nvm|nvm4w)\//.test(p))
return 'nvm';
if (/\/(\.fnm|fnm|fnm_multishells)\//.test(p))
return 'fnm';
if (/\/(\.volta|volta)\//.test(p))
return 'Volta';
if (p.includes('/.asdf/'))
return 'asdf';
if (/\/mise\/installs\//.test(p))
return 'mise';
if (/\/(claude\.app|anthropicclaude)\//.test(p))
return "Claude Desktop's bundled Node";
if (/^\/(opt\/homebrew|usr\/local\/cellar|home\/linuxbrew)\//.test(p))
return 'Homebrew';
if (p.includes('/program files/nodejs/') || /^\/usr\/(local\/)?bin\//.test(p))
return 'global install';
return 'unknown';
}
function npmVersion() {
return new Promise((resolve) => {
// A fixed command line through the shell: npm is npm.cmd on Windows, which needs one
exec('npm --version', {
timeout: NPM_TIMEOUT_MS,
windowsHide: true,
// --version needs no registry: keep npm's update check off the network
env: { ...process.env, npm_config_update_notifier: 'false' },
}, (error, stdout) => {
const version = String(stdout).trim();
resolve(!error && /^\d+\.\d+\.\d+\S*$/.test(version) ? version : null);
});
});
}
function macosVersion() {
return new Promise((resolve) => {
execFile('sw_vers', ['-productVersion'], { timeout: 2000 }, (error, stdout) => {
resolve(error ? null : String(stdout).trim() || null);
});
});
}
async function osName() {
if (process.platform === 'win32')
return os.version() || 'Windows';
if (process.platform === 'darwin') {
const version = await macosVersion();
return version ? `macOS ${version}` : 'macOS';
}
if (process.platform === 'linux') {
try {
const release = fs.readFileSync('/etc/os-release', 'utf8');
const pretty = release.match(/^PRETTY_NAME="?([^"\n]*)"?$/m)?.[1];
if (pretty)
return pretty;
}
catch { /* fall through */ }
return 'Linux';
}
return os.type();
}
// --- network ----------------------------------------------------------------------------
/** DNS, then the TLS handshake (https) or TCP connect (http). `name` is how the report shows the host. */
async function timeHost(url, name) {
const host = url.hostname;
const result = { host: name };
try {
const startedAt = process.hrtime.bigint();
await withTimeout(dns.promises.lookup(host), CONNECT_TIMEOUT_MS);
result.dnsMs = elapsed(startedAt);
}
catch (error) {
result.error = `DNS failed (${errorText(error)})`;
return result;
}
const secure = url.protocol === 'https:' || url.protocol === 'wss:';
const port = Number(url.port) || (secure ? 443 : 80);
try {
const startedAt = process.hrtime.bigint();
await new Promise((resolve, reject) => {
const socket = secure
? tls.connect({ host, port, servername: net.isIP(host) ? undefined : host }, () => { socket.destroy(); resolve(); })
: net.connect({ host, port }, () => { socket.destroy(); resolve(); });
socket.setTimeout(CONNECT_TIMEOUT_MS, () => { socket.destroy(); reject(new Error('timed out')); });
socket.on('error', reject);
});
result.connect = { kind: secure ? 'TLS' : 'TCP', ms: elapsed(startedAt) };
}
catch (error) {
result.error = `${secure ? 'TLS' : 'TCP'} failed (${errorText(error)})`;
}
return result;
}
async function checkMcpInfo(serverUrl) {
const ms = [];
const statuses = [];
const offsets = [];
let info = null;
let error;
for (let round = 0; round < MCP_INFO_ROUNDS; round++) {
const sentAt = Date.now();
const startedAt = process.hrtime.bigint();
try {
const response = await fetch(`${serverUrl}/api/mcp-info`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
const body = await response.text();
ms.push(elapsed(startedAt));
statuses.push(response.status);
const serverMs = Date.parse(response.headers.get('date') ?? '');
if (!Number.isNaN(serverMs))
offsets.push(serverMs - (sentAt + Date.now()) / 2);
if (response.ok && !info) {
try {
info = JSON.parse(body);
}
catch {
error = 'mcp-info is not JSON';
}
}
}
catch (err) {
error = errorText(err);
// Stop at the first failure: four more timeouts would only make the user wait
break;
}
}
return { ms, statuses, offsets, info, error };
}
async function checkRest(supabaseUrl, key) {
const startedAt = process.hrtime.bigint();
try {
const response = await fetch(`${supabaseUrl}/rest/v1/`, {
headers: { apikey: key },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
await response.arrayBuffer();
return { status: response.status, ms: elapsed(startedAt) };
}
catch (error) {
return { error: errorText(error) };
}
}
/** Node 22+ has WebSocket built in; older Node uses `ws`, which realtime-js depends on. */
function webSocketClass() {
if (typeof globalThis.WebSocket === 'function')
return globalThis.WebSocket;
try {
return createRequire(import.meta.url)('ws');
}
catch {
return null;
}
}
function checkRealtime(supabaseUrl, key) {
const WebSocketClass = webSocketClass();
if (!WebSocketClass)
return Promise.resolve({ heartbeat: false, error: 'no WebSocket in this Node version' });
const url = `${supabaseUrl.replace(/^http/, 'ws')}/realtime/v1/websocket?apikey=${encodeURIComponent(key)}&vsn=1.0.0`;
return new Promise((resolve) => {
const result = { heartbeat: false };
const startedAt = process.hrtime.bigint();
let heartbeatAt;
let socket;
let done = false;
const finish = (error) => {
// The close that follows a finished check is not a failure
if (done)
return;
done = true;
clearTimeout(timer);
if (error)
result.error = error;
try {
socket?.close();
}
catch { /* already closed */ }
resolve(result);
};
const timer = setTimeout(() => finish(result.openedMs === undefined ? 'opening timed out' : 'heartbeat not answered in time'), REQUEST_TIMEOUT_MS);
try {
socket = new WebSocketClass(url);
}
catch (error) {
finish(errorText(error));
return;
}
socket.onopen = () => {
result.openedMs = elapsed(startedAt);
heartbeatAt = process.hrtime.bigint();
socket.send(JSON.stringify({ topic: 'phoenix', event: 'heartbeat', payload: {}, ref: '1' }));
};
socket.onmessage = (event) => {
try {
const message = JSON.parse(String(event.data));
if (message.ref === '1' && message.event === 'phx_reply') {
result.heartbeat = message.payload?.status === 'ok';
result.heartbeatMs = elapsed(heartbeatAt);
finish(result.heartbeat ? undefined : 'heartbeat refused');
}
}
catch { /* not ours */ }
};
socket.onerror = (event) => finish(event?.message ? errorText(event) : 'connection failed');
socket.onclose = (event) => finish(`closed (${event?.code ?? '?'})`);
});
}
/** "[[dd-]hh:]mm:ss" from ps, in seconds. */
function elapsedSeconds(etime) {
const [days, clock] = etime.includes('-') ? etime.split('-') : ['0', etime];
const parts = clock.split(':').map(Number);
while (parts.length < 3)
parts.unshift(0);
return Number(days) * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2];
}
/**
* Every process with its command line and start time. process.ts's list uses
* tasklist on Windows, which has no command lines, hence Win32_Process here.
*/
function listProcesses() {
const options = { timeout: PROCESS_LIST_TIMEOUT_MS, windowsHide: true, maxBuffer: 32 * 1024 * 1024 };
return new Promise((resolve, reject) => {
if (process.platform === 'win32') {
const script = '[Console]::OutputEncoding = [Text.Encoding]::UTF8; Get-CimInstance Win32_Process | ForEach-Object { ' +
'"$($_.ProcessId)`t$(if ($_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString(\'o\') })`t$($_.CommandLine)" }';
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], options, (error, stdout) => {
if (error)
return reject(error);
resolve(String(stdout).split(/\r?\n/).map((line) => {
const [pid, created, ...command] = line.split('\t');
return { pid: Number(pid), startedAt: Date.parse(created), command: command.join('\t') };
}).filter((p) => p.pid > 0 && p.command));
});
return;
}
execFile('ps', ['-A', '-ww', '-o', 'pid=,etime=,command='], options, (error, stdout) => {
if (error)
return reject(error);
const now = Date.now();
resolve(String(stdout).split('\n').flatMap((line) => {
const match = line.match(/^\s*(\d+)\s+(\S+)\s+(.*)$/);
return match ? [{ pid: Number(match[1]), startedAt: now - elapsedSeconds(match[2]) * 1000, command: match[3] }] : [];
}));
});
});
}
/** Whether `file` is Desktop Commander's dist/index.js: a bin link is followed, then the package.json beside dist/ named. */
function isDesktopCommanderEntry(file, packageNames) {
let entry;
try {
entry = fs.realpathSync(file);
}
catch {
return false;
}
if (path.basename(entry) !== 'index.js' || path.basename(path.dirname(entry)) !== 'dist')
return false;
const root = path.dirname(path.dirname(entry));
if (!packageNames.has(root)) {
try {
packageNames.set(root, JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name ?? null);
}
catch {
packageNames.set(root, null);
}
}
return PACKAGE_NAMES.has(packageNames.get(root) ?? '');
}
/**
* Processes running Desktop Commander's MCP server: a command line with its
* dist/index.js (started by an MCP client, npx, or `remote`'s local MCP), not
* followed by `remote` / `setup` / `remove`, and not this report. A shell
* wrapping it names only a bin, which doesn't resolve, so it isn't counted
* twice. Only the count and the earliest start leave this function.
*/
async function desktopCommanderMcp() {
let processes;
try {
processes = await listProcesses();
}
catch (error) {
return { running: null, since: null, error: `could not list processes (${errorText(error)})` };
}
const packageNames = new Map();
const servers = processes.filter(({ pid, command }) => {
if (pid === process.pid)
return false;
const tokens = (command.match(/"[^"]*"|\S+/g) ?? []).map((token) => token.replace(/^"|"$/g, ''));
// The first token is the executable; the entry is any later one
const at = tokens.findIndex((token, i) => i > 0 && /(\.js|desktop-commander(\.cmd)?)$/i.test(token) &&
isDesktopCommanderEntry(token, packageNames));
return at > 0 && !NOT_THE_SERVER.has(tokens[at + 1] ?? '');
});
const starts = servers.map((p) => p.startedAt).filter((t) => !Number.isNaN(t));
return {
running: servers.length,
since: starts.length ? new Date(Math.min(...starts)).toISOString() : null,
};
}
function proxySet(...names) {
return names.some((name) => Boolean(process.env[name]));
}
// --- device state, settings, device log --------------------------------------------------
function deviceState(home) {
const file = path.join(home, '.desktop-commander-device', 'device.json');
const state = {
deviceJson: false, parses: false, id: null, session: false,
accessToken: false, refreshToken: false, savedAt: null,
};
let text;
try {
state.savedAt = new Date(fs.statSync(file).mtimeMs).toISOString();
text = fs.readFileSync(file, 'utf8');
state.deviceJson = true;
}
catch {
return state;
}
try {
const config = JSON.parse(text);
state.parses = true;
// Only an id-shaped value: whatever else a hand-edited file holds stays out
const id = config?.deviceId;
state.id = typeof id === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null;
const session = config?.session;
state.session = Boolean(session) && typeof session === 'object';
state.accessToken = typeof session?.access_token === 'string' && session.access_token.length > 0;
state.refreshToken = typeof session?.refresh_token === 'string' && session.refresh_token.length > 0;
}
catch { /* parses stays false */ }
return state;
}
function settings(home) {
try {
// As Desktop Commander reads it: a file saved with a UTF-8 BOM too
const config = parseConfig(fs.readFileSync(path.join(home, '.claude-server-commander', 'config.json'), 'utf8'));
const clientId = typeof config?.clientId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(config.clientId) ? config.clientId : null;
return {
telemetryEnabled: typeof config?.telemetryEnabled === 'boolean' ? config.telemetryEnabled : null,
clientId,
};
}
catch {
return { telemetryEnabled: null, clientId: null };
}
}
function readTail(file) {
const size = fs.statSync(file).size;
if (size <= MAX_LOG_READ_BYTES)
return fs.readFileSync(file, 'utf8');
const fd = fs.openSync(file, 'r');
try {
const buffer = Buffer.alloc(MAX_LOG_READ_BYTES);
fs.readSync(fd, buffer, 0, MAX_LOG_READ_BYTES, size - MAX_LOG_READ_BYTES);
return buffer.toString('utf8');
}
finally {
fs.closeSync(fd);
}
}
const LOG_LINE = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ) {2}(.*)$/;
/** A rotated file's number: remote-mon.2.log is 2, remote-mon.log 0. */
const rotation = (name) => Number(name.match(/\.(\d+)\.log$/)?.[1] ?? 0);
/**
* The device log files (the 21 fixed names that exist), oldest first by
* modification time, each timestamped line cleaned and masked again.
*/
function deviceLog(dir) {
const parts = [];
const summary = { files: 0, lines: 0, first: null, last: null, lastText: null };
const files = deviceLogNames().flatMap((name) => {
try {
return [{ name, mtime: fs.statSync(path.join(dir, name)).mtimeMs }];
}
catch {
return [];
}
}).sort((a, b) => a.mtime - b.mtime || rotation(b.name) - rotation(a.name));
for (const { name } of files) {
let raw;
try {
raw = readTail(path.join(dir, name));
}
catch {
continue;
}
const kept = [];
for (const line of raw.split(/\r?\n/)) {
const match = line.match(LOG_LINE);
const text = match && cleanLine(match[2]);
if (!match || !text)
continue;
kept.push(`${match[1]} ${text}`);
summary.first ?? (summary.first = match[1]);
summary.last = match[1];
summary.lastText = text;
}
summary.files++;
summary.lines += kept.length;
parts.push({ name, text: kept.length ? kept.join('\n') + '\n' : '' });
}
return { parts, summary };
}
// --- the report ---------------------------------------------------------------------------
/** Hosts on this machine: an http address there never crosses the network (a local stand-in). */
const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '[::1]'];
/** The diagnostics Worker's address from /api/mcp-info: https, or http on this machine only. */
function uploadUrl(value) {
if (typeof value !== 'string')
return null;
try {
const url = new URL(value);
return url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK_HOSTS.includes(url.hostname)) ? value : null;
}
catch {
return null;
}
}
export async function collectReport() {
const home = os.homedir();
const now = Date.now();
const serverUrl = (process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL).replace(/\/+$/, '');
const server = new URL(serverUrl);
const [name, npm, mcp, serverTiming] = await Promise.all([
osName(), npmVersion(), desktopCommanderMcp(), timeHost(server, server.host),
]);
const mcpInfo = await checkMcpInfo(serverUrl);
let supabase;
const supabaseUrl = mcpInfo.info?.supabaseUrl?.replace(/\/+$/, '');
const key = mcpInfo.info?.supabasePublishableKey;
if (supabaseUrl && key) {
// Shown as "Supabase": the project's address isn't needed to read the report
const timing = await timeHost(new URL(supabaseUrl), 'Supabase');
const rest = await checkRest(supabaseUrl, key);
const realtime = await checkRealtime(supabaseUrl, key);
supabase = { ...timing, rest, realtime };
}
else {
supabase = { error: mcpInfo.error ? 'skipped: no answer from /api/mcp-info' : 'skipped: /api/mcp-info gave no Supabase address' };
}
const log = deviceLog(getDeviceLogDir());
const report = {
format: 1,
createdAt: new Date(now).toISOString(),
versions: {
desktopCommander: VERSION,
node: process.versions.node,
npm,
os: { name, release: os.release(), arch: os.arch() },
// Paths with the home folder as ~: the user name never appears, the rest of the path does
nodePath: redact(process.execPath),
nodeKind: nodeKind(process.execPath),
entryPath: process.argv[1] ? redact(process.argv[1]) : 'unknown',
runKind: runKind(),
},
clock: mcpInfo.offsets.length
? { serverAheadSeconds: Math.round(median(mcpInfo.offsets) / 1000) }
: { serverAheadSeconds: null, error: 'no Date header from the server' },
network: {
server: { ...serverTiming, mcpInfo: { ms: mcpInfo.ms, statuses: mcpInfo.statuses, error: mcpInfo.error } },
supabase,
proxy: { httpsProxy: proxySet('HTTPS_PROXY', 'https_proxy'), httpProxy: proxySet('HTTP_PROXY', 'http_proxy') },
},
device: deviceState(home),
settings: settings(home),
deviceLog: log.summary,
desktopCommanderMcp: mcp,
};
return { report, logParts: log.parts, diagnosticsUrl: uploadUrl(mcpInfo.info?.diagnosticsUrl) };
}
function networkOk(network) {
const { server, supabase } = network;
if (server.error || server.mcpInfo.error || server.mcpInfo.ms.length < MCP_INFO_ROUNDS)
return false;
if (server.mcpInfo.statuses.some((status) => status !== 200))
return false;
if ('rest' in supabase) {
const { rest, realtime } = supabase;
return !supabase.error && !rest.error && (rest.status ?? 0) < 500 && realtime.heartbeat;
}
return false;
}
function hostLine(timing) {
const parts = [];
if (timing.dnsMs !== undefined)
parts.push(`DNS ${timing.dnsMs} ms`);
if (timing.connect)
parts.push(`${timing.connect.kind} ${timing.connect.ms} ms`);
if (timing.error)
parts.push(timing.error);
return parts.join(' · ');
}
/**
* The REST probe has no sign-in, so any HTTP answer (401 included) means
* Supabase is reachable; only a 5xx is the server's own failure. report.json
* keeps the raw status.
*/
function restText(rest) {
if (rest.error || rest.status === undefined)
return `not reachable (${rest.error ?? 'no answer'})`;
if (rest.status >= 500)
return `answered with a server error (${rest.status}) in ${rest.ms} ms`;
return `reachable in ${rest.ms} ms`;
}
function localTimestamp(date) {
const pad = (n) => String(n).padStart(2, '0');
const offset = -date.getTimezoneOffset();
const hours = Math.trunc(Math.abs(offset) / 60);
const minutes = Math.abs(offset) % 60;
const zone = `UTC${offset < 0 ? '-' : '+'}${hours}${minutes ? `:${pad(minutes)}` : ''}`;
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())} (${zone})`;
}
/** An ISO time (UTC) to the minute: "2026-10-05 14:21". */
const utcMinute = (iso) => iso.slice(0, 16).replace('T', ' ');
/** "running (2 copies, since 14:02)": the time alone if it's today, else with the date. */
function mcpText(mcp) {
if (mcp.running === null)
return `unknown (${mcp.error})`;
if (mcp.running === 0)
return 'not running';
const copies = mcp.running === 1 ? '1 copy' : `${mcp.running} copies`;
if (!mcp.since)
return `running (${copies})`;
const pad = (n) => String(n).padStart(2, '0');
const since = new Date(mcp.since);
const day = `${since.getFullYear()}-${pad(since.getMonth() + 1)}-${pad(since.getDate())}`;
const time = `${pad(since.getHours())}:${pad(since.getMinutes())}`;
const today = new Date();
const isToday = day === `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
return `running (${copies}, since ${isToday ? time : `${day} ${time}`})`;
}
export function formatReport(report) {
const label = (name) => name.padEnd(14);
const indent = ' '.repeat(14);
const lines = [];
const { versions, clock, network, device, settings: config, deviceLog: log } = report;
lines.push(`Desktop Commander diagnostics — ${localTimestamp(new Date(report.createdAt))}`);
lines.push('This file contains: versions, how Node runs (paths, with the home folder as ~), clock, network checks, ' +
'the device id and whether sign-in data exists (yes/no), device status history.');
lines.push('It does not contain: tokens, passwords, emails, the user name, tool arguments or results.');
lines.push('');
lines.push(`${label('Versions')}Desktop Commander ${versions.desktopCommander} · Node ${versions.node} · ` +
`npm ${versions.npm ?? 'not found'} · ${versions.os.name} (${versions.os.release}) ${versions.os.arch}`);
lines.push(`${label('Node')}${versions.nodePath} (${versions.nodeKind})`);
lines.push(`${label('Running')}${versions.entryPath} (${versions.runKind})`);
lines.push(`${label('MCP')}${mcpText(report.desktopCommanderMcp)}`);
const ahead = clock.serverAheadSeconds;
lines.push(`${label('Clock')}${ahead === null ? `unknown (${clock.error})`
: Math.abs(ahead) <= 1 ? 'device matches the server (within 1 s, from the server\'s Date header)'
: `device is ${Math.abs(ahead)} s ${ahead > 0 ? 'behind' : 'ahead of'} the server (from the server's Date header)`}`);
const { server, supabase, proxy } = network;
const info = server.mcpInfo;
const infoText = info.ms.length
? `/api/mcp-info ${info.ms.length}× ${Math.min(...info.ms)}/${median(info.ms)}/${Math.max(...info.ms)} ms (min/median/max)` +
(info.statuses.some((s) => s !== 200) ? `, statuses ${info.statuses.join(', ')}` : '') +
(info.error ? `, then failed: ${info.error}` : '')
: `/api/mcp-info failed: ${info.error}`;
lines.push(`${label('Network')}${server.host}: ${[hostLine(server), infoText].filter(Boolean).join(' · ')}`);
if ('rest' in supabase) {
const rt = supabase.realtime;
const realtime = rt.openedMs === undefined
? `realtime websocket not opened (${rt.error})`
: `realtime websocket opened in ${rt.openedMs} ms, ${rt.heartbeat ? 'heartbeat answered' : `heartbeat ${rt.error}`}`;
lines.push(`${indent}Supabase: ${[hostLine(supabase), restText(supabase.rest), realtime].filter(Boolean).join(' · ')}`);
}
else {
lines.push(`${indent}Supabase: ${supabase.error}`);
}
lines.push(`${indent}Proxy: HTTPS_PROXY ${proxy.httpsProxy ? 'set' : 'not set'} · HTTP_PROXY ${proxy.httpProxy ? 'set' : 'not set'}`);
const saved = device.savedAt === null ? '' : `, saved ${utcMinute(device.savedAt)} UTC`;
lines.push(`${label('Device')}${!device.deviceJson ? 'signed-in data: no (no device.json)'
: !device.parses ? `signed-in data: device.json does not parse${saved}`
// Signed in means a token is really there, not just a session object
: `id ${device.id ?? 'none'} · signed-in data: ${yesNo(device.accessToken || device.refreshToken)} ` +
`(access token: ${yesNo(device.accessToken)}, refresh token: ${yesNo(device.refreshToken)})${saved}`}`);
const telemetry = config.telemetryEnabled === null ? 'not set' : config.telemetryEnabled ? 'on' : 'off';
lines.push(`${label('Settings')}telemetry: ${telemetry} · client id: ${config.clientId ?? 'none'}` +
(config.clientId ? ' (lets us find this device\'s telemetry)' : ''));
lines.push(`${label('Device log')}${log.lines === 0
? (log.files ? `${log.files} file(s), no lines` : 'none yet (it is written while `remote` runs)')
: `${log.lines.toLocaleString('en-US')} lines from ${utcMinute(log.first)} to ${utcMinute(log.last)} UTC; last: "${log.lastText}"`}`);
return lines.join('\n') + '\n';
}
function zipName(home, date) {
const pad = (n) => String(n).padStart(2, '0');
const stamp = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
for (let n = 1;; n++) {
const file = path.join(home, `desktop-commander-report-${stamp}${n > 1 ? `-${n}` : ''}.zip`);
if (!fs.existsSync(file))
return file;
}
}
/** Collects the report and writes the zip to the home folder; returns its bytes for the upload too. */
export async function writeReport() {
const { report, logParts, diagnosticsUrl } = await collectReport();
const zip = new PizZip();
zip.file('report.txt', formatReport(report));
zip.file('report.json', JSON.stringify(report, null, 2) + '\n');
for (const part of logParts)
zip.file(`device-log/${part.name}`, part.text);
const buffer = zip.generate({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } });
const file = zipName(os.homedir(), new Date(report.createdAt));
// Owner-only, like the device log
fs.writeFileSync(file, buffer, { flag: 'wx', mode: 0o600 });
return { file, bytes: buffer.length, zip: buffer, report, diagnosticsUrl };
}
/** `remote --report`: the terminal side. */
export async function runReport() {
console.log('Collecting diagnostics… (about 10 s)');
let result;
try {
result = await writeReport();
}
catch (error) {
console.error(`❌ Could not save the diagnostics report: ${errorText(error)}`);
process.exitCode = 1;
return;
}
const { report, file, bytes, zip, diagnosticsUrl } = result;
const mark = (ok) => (ok ? '✓' : '✗');
const log = report.deviceLog;
console.log(' ' + [
`${mark(true)} versions`,
`${mark(report.clock.serverAheadSeconds !== null)} clock`,
`${mark(networkOk(report.network))} network`,
`${mark(true)} device state`,
// No log yet is a fact, not a failed check
`${mark(true)} device log (${log.files ? `${log.files} file${log.files === 1 ? '' : 's'}` : 'none yet'})`,
].join(' '));
console.log(`Saved: ${file} (${Math.max(1, Math.round(bytes / 1024))} KB)`);
console.log('It holds no passwords, tokens, emails or file contents; you can open it and check.');
if (process.argv.includes('--no-upload')) {
console.log('Reply to your support conversation with this file attached.');
return;
}
try {
const id = await uploadReport(zip, { diagnosticsUrl, userId: savedUserId(os.homedir()), deviceId: report.device.id });
console.log(`Sent to Desktop Commander support. Report id: ${id}`);
console.log('Give this id to support. We keep it for 7 days, then delete it.');
}
catch (error) {
// The zip is saved either way
console.log(`Not sent (${errorText(error)}). Attach the zip to your support conversation instead.`);
}
}