@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
240 lines (226 loc) • 7.57 kB
JavaScript
import { URL } from 'node:url';
import { createApiClient, getBuilds } from '../api/index.js';
import { ConfigError } from '../errors/vizzly-error.js';
import { loadConfig } from '../utils/config-loader.js';
import { getContext } from '../utils/context.js';
import { getApiToken } from '../utils/environment-config.js';
import * as output from '../utils/output.js';
/**
* Doctor command implementation - Run diagnostics to check environment
* @param {Object} options - Command options
* @param {Object} globalOptions - Global CLI options
*/
export async function doctorCommand(options = {}, globalOptions = {}) {
output.configure({
json: globalOptions.json,
verbose: globalOptions.verbose,
color: !globalOptions.noColor
});
let diagnostics = {
environment: {
nodeVersion: null,
nodeVersionValid: null
},
configuration: {
apiUrl: null,
apiUrlValid: null,
threshold: null,
thresholdValid: null,
port: null
},
connectivity: {
checked: false,
ok: null,
error: null
}
};
let hasErrors = false;
let checks = [];
try {
// Determine if we'll attempt remote checks (API connectivity)
let willCheckConnectivity = Boolean(options.api || getApiToken());
// Show header
output.header('doctor', willCheckConnectivity ? 'full' : 'local');
// Node.js version check (require >= 20)
let nodeVersion = process.version;
let nodeMajor = parseInt(nodeVersion.slice(1).split('.')[0], 10);
diagnostics.environment.nodeVersion = nodeVersion;
diagnostics.environment.nodeVersionValid = nodeMajor >= 20;
if (nodeMajor >= 20) {
checks.push({
name: 'Node.js',
value: `${nodeVersion} (supported)`,
ok: true
});
} else {
checks.push({
name: 'Node.js',
value: `${nodeVersion} (requires >= 20)`,
ok: false
});
hasErrors = true;
}
// Load configuration (apply global CLI overrides like --config only)
let config = await loadConfig(globalOptions.config);
// Validate apiUrl
diagnostics.configuration.apiUrl = config.apiUrl;
try {
let url = new URL(config.apiUrl);
if (!['http:', 'https:'].includes(url.protocol)) {
throw new ConfigError('URL must use http or https');
}
diagnostics.configuration.apiUrlValid = true;
checks.push({
name: 'API URL',
value: config.apiUrl,
ok: true
});
} catch (_e) {
diagnostics.configuration.apiUrlValid = false;
checks.push({
name: 'API URL',
value: 'invalid (check VIZZLY_API_URL)',
ok: false
});
hasErrors = true;
}
// Validate threshold (0..1 inclusive)
let threshold = Number(config?.comparison?.threshold);
diagnostics.configuration.threshold = threshold;
// CIEDE2000 threshold: 0 = exact, 1 = JND, 2 = recommended, 3+ = permissive
let thresholdValid = Number.isFinite(threshold) && threshold >= 0;
diagnostics.configuration.thresholdValid = thresholdValid;
if (thresholdValid) {
checks.push({
name: 'Threshold',
value: `${threshold} (CIEDE2000)`,
ok: true
});
} else {
checks.push({
name: 'Threshold',
value: 'invalid',
ok: false
});
hasErrors = true;
}
// Report effective port without binding
let port = config?.server?.port ?? 47392;
diagnostics.configuration.port = port;
checks.push({
name: 'Port',
value: String(port),
ok: true
});
// Optional: API connectivity check when --api is provided or VIZZLY_TOKEN is present
let autoApi = Boolean(getApiToken());
if (options.api || autoApi) {
diagnostics.connectivity.checked = true;
if (!config.apiKey) {
diagnostics.connectivity.ok = false;
diagnostics.connectivity.error = 'Missing API token (VIZZLY_TOKEN)';
checks.push({
name: 'API Token',
value: 'missing',
ok: false
});
hasErrors = true;
} else {
output.startSpinner('Checking API connectivity...');
try {
let client = createApiClient({
baseUrl: config.apiUrl,
token: config.apiKey,
command: 'doctor'
});
// Minimal, read-only call
await getBuilds(client, {
limit: 1
});
output.stopSpinner();
diagnostics.connectivity.ok = true;
checks.push({
name: 'API',
value: 'connected',
ok: true
});
} catch (err) {
output.stopSpinner();
diagnostics.connectivity.ok = false;
diagnostics.connectivity.error = err?.message || String(err);
checks.push({
name: 'API',
value: 'connection failed',
ok: false
});
hasErrors = true;
}
}
}
// Output results
if (globalOptions.json) {
// JSON mode - structured output only
output.data({
passed: !hasErrors,
diagnostics,
timestamp: new Date().toISOString()
});
} else {
// Human-readable output - display results as a checklist
// Use printErr to match header (both on stderr for consistent ordering)
let colors = output.getColors();
for (let check of checks) {
let icon = check.ok ? colors.brand.success('✓') : colors.brand.danger('✗');
let label = colors.brand.textTertiary(check.name.padEnd(12));
output.printErr(` ${icon} ${label} ${check.value}`);
}
output.printErr('');
// Summary
if (hasErrors) {
output.warn('Preflight completed with issues');
} else {
output.printErr(` ${colors.brand.success('✓')} Preflight passed`);
}
// Dynamic context section (same as help output)
let contextItems = getContext();
if (contextItems.length > 0) {
output.printErr('');
output.printErr(` ${colors.dim('─'.repeat(52))}`);
for (let item of contextItems) {
if (item.type === 'success') {
output.printErr(` ${colors.green('✓')} ${colors.gray(item.label)} ${colors.white(item.value)}`);
} else if (item.type === 'warning') {
output.printErr(` ${colors.yellow('!')} ${colors.gray(item.label)} ${colors.yellow(item.value)}`);
} else {
output.printErr(` ${colors.dim('○')} ${colors.gray(item.label)} ${colors.dim(item.value)}`);
}
}
}
// Footer with links
output.printErr('');
output.printErr(` ${colors.dim('─'.repeat(52))}`);
output.printErr(` ${colors.dim('Docs')} ${colors.cyan(colors.underline('docs.vizzly.dev'))} ${colors.dim('GitHub')} ${colors.cyan(colors.underline('github.com/vizzly-testing/cli'))}`);
// Emit structured data in verbose mode (in addition to visual output)
if (globalOptions.verbose) {
output.data({
passed: !hasErrors,
diagnostics,
timestamp: new Date().toISOString()
});
}
}
} catch (error) {
hasErrors = true;
output.error('Failed to run preflight', error);
} finally {
output.cleanup();
if (hasErrors) process.exit(1);
}
}
/**
* Validate doctor options (no specific validation needed)
* @param {Object} options - Command options
*/
export function validateDoctorOptions() {
return []; // No specific validation for now
}