All files / bin/commands doctor.js

17.89% Statements 46/257
100% Branches 1/1
0% Functions 0/5
17.89% Lines 46/257

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258250x 250x 250x 250x 250x 250x 250x 250x 250x 250x 250x 250x 250x 250x 250x 250x 250x 250x 250x 250x 250x               250x 250x 250x 250x 250x 250x               250x 250x 250x 250x 250x 250x 250x                     250x 250x 250x 250x 250x 250x                 250x 250x 250x 250x 250x 250x                                                                                                                                                                                                                                                                                                                                                                        
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { bold, cyan, green, yellow, red, gray } from '../colors.js';
import { CONFIG_SCHEMA } from '../../lib/config.js';
 
const MIN_NODE_VERSION = [18, 0, 0];
 
// One schema, owned by lib/config.js. Doctor used to keep its own copy and
// drifted, warning that valid options such as server.headers were unknown.
const ALLOWED_TOP_LEVEL = CONFIG_SCHEMA.topLevel;
const ALLOWED_SERVER = CONFIG_SCHEMA.server;
const ALLOWED_STYLE = CONFIG_SCHEMA.style;
const ALLOWED_DEBUG = CONFIG_SCHEMA.debug;
const ALLOWED_LOGGING = CONFIG_SCHEMA.logging;
 
/**
 * @param {number[]} current
 * @param {number[]} required
 * @returns {boolean}
 */
function compareVersions(current, required) {
  for (let i = 0; i < required.length; i++) {
    if ((current[i] || 0) > required[i]) return true;
    if ((current[i] || 0) < required[i]) return false;
  }
  return true;
}
 
/**
 * @param {'pass'|'warn'|'fail'} status
 * @param {string} message
 * @param {string} [hint]
 */
function printCheck(status, message, hint) {
  const icons = { pass: green('✔'), warn: yellow('⚠'), fail: red('✖') };
  console.log(`  ${icons[status]} ${message}`);
  if (hint) {
    console.log(`    ${gray(`→ ${hint}`)}`);
  }
}
 
/**
 * @param {object} obj
 * @param {string[]} allowed
 * @param {string} prefix
 * @returns {string[]}
 */
function collectUnknownKeys(obj, allowed, prefix = '') {
  const unknown = [];
  if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return unknown;
  for (const key of Object.keys(obj)) {
    if (!allowed.includes(key)) {
      unknown.push(prefix ? `${prefix}.${key}` : key);
    }
  }
  return unknown;
}
 
/**
 * Prefer an explicit app/framework root over findProjectRoot skipping avenx-core.
 * @param {object} cli
 * @returns {string}
 */
function resolveDoctorRoot(cli) {
  const cwd = process.cwd();
  const cwdPkgPath = path.join(cwd, 'package.json');
  if (fs.existsSync(cwdPkgPath)) {
    return cwd;
  }
  return cli.baseDir;
}
 
/**
 * Runs environment and project health diagnostics.
 * @param {object} cli
 */
export function runDoctor(cli) {
  const root = resolveDoctorRoot(cli);
  const results = { pass: 0, warn: 0, fail: 0 };
  /**
   * @param {'pass'|'warn'|'fail'} status
   * @param {string} message
   * @param {string} [hint]
   */
  const record = (status, message, hint) => {
    results[status] += 1;
    printCheck(status, message, hint);
  };

  console.log(bold(cyan('Avenx Doctor')));
  console.log(`${gray(`Project root: ${root}`)}\n`);

  // --- Node.js ---
  console.log(bold('Node.js'));
  const current = process.versions.node.split('.').map(Number);
  if (compareVersions(current, MIN_NODE_VERSION)) {
    record('pass', `Node.js ${process.versions.node} (>= ${MIN_NODE_VERSION.join('.')})`);
  } else {
    record(
      'fail',
      `Node.js ${process.versions.node} is below the required ${MIN_NODE_VERSION.join('.')}`,
      'Upgrade Node.js to version 18 or later.',
    );
  }

  // --- package.json ---
  console.log(`\n${bold('Project files')}`);
  const pkgPath = path.join(root, 'package.json');
  let isFrameworkRepo = false;
  if (fs.existsSync(pkgPath)) {
    try {
      const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
      if (pkg.name === 'avenx-core') {
        isFrameworkRepo = true;
        record(
          'warn',
          'Running inside the avenx-core framework repository',
          'Doctor is intended for application projects created with `avenx init`.',
        );
      } else {
        record('pass', `package.json found (name: ${pkg.name || 'unnamed'})`);
      }
    } catch (err) {
      record('fail', 'package.json exists but is not valid JSON', err.message);
    }
  } else {
    record('fail', 'package.json not found', 'Run doctor from an Avenx project root, or create one with `avenx init`.');
  }

  // --- avenx.config.json ---
  const configPath = path.join(root, 'avenx.config.json');
  let userConfig = null;
  if (!fs.existsSync(configPath)) {
    record(
      'warn',
      'avenx.config.json not found (defaults will be used)',
      'Run `avenx init` or add an avenx.config.json at the project root.',
    );
  } else {
    try {
      userConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
      if (!userConfig || typeof userConfig !== 'object' || Array.isArray(userConfig)) {
        record('fail', 'avenx.config.json must be a JSON object');
      } else {
        record('pass', 'avenx.config.json parsed successfully');
        const unknown = collectUnknownKeys(userConfig, ALLOWED_TOP_LEVEL);
        for (const key of unknown) {
          record('warn', `Unrecognized config field "${key}"`, `Supported top-level options: ${ALLOWED_TOP_LEVEL.join(', ')}`);
        }
        if (userConfig.server) {
          for (const key of collectUnknownKeys(userConfig.server, ALLOWED_SERVER, 'server')) {
            record('warn', `Unrecognized config field "${key}"`);
          }
        }
        if (userConfig.style) {
          for (const key of collectUnknownKeys(userConfig.style, ALLOWED_STYLE, 'style')) {
            record('warn', `Unrecognized config field "${key}"`);
          }
        }
        if (userConfig.debug) {
          for (const key of collectUnknownKeys(userConfig.debug, ALLOWED_DEBUG, 'debug')) {
            record('warn', `Unrecognized config field "${key}"`);
          }
        }
        if (userConfig.logging) {
          for (const key of collectUnknownKeys(userConfig.logging, ALLOWED_LOGGING, 'logging')) {
            record('warn', `Unrecognized config field "${key}"`);
          }
        }
      }
    } catch (err) {
      record('fail', 'avenx.config.json contains malformed JSON', err.message);
    }
  }

  // --- Directories ---
  console.log(`\n${bold('Project structure')}`);
  const srcRel = (userConfig && userConfig.srcDir) || cli.config.srcDir || 'src';
  const distRel = (userConfig && userConfig.distDir) || cli.config.distDir || 'dist';
  const srcDir = path.join(root, srcRel);
  const distDir = path.join(root, distRel);
  if (fs.existsSync(srcDir) && fs.statSync(srcDir).isDirectory()) {
    record('pass', `Source directory present (${srcRel})`);
  } else if (isFrameworkRepo) {
    record(
      'warn',
      `App source directory "${srcRel}" not found (expected for framework checkout)`,
      'Application projects use src/; the framework sources live under lib/.',
    );
  } else {
    record('fail', `Missing source directory "${srcRel}"`, 'Create it or set srcDir in avenx.config.json.');
  }

  if (fs.existsSync(distDir) && fs.statSync(distDir).isDirectory()) {
    record('pass', `Build output directory present (${distRel})`);
  } else {
    record(
      'warn',
      `Build output directory "${distRel}" not found`,
      'This is normal before the first `avenx build`.',
    );
  }

  const expectedSrcChildren = ['components', 'pages', 'global'];
  for (const child of expectedSrcChildren) {
    const childPath = path.join(srcDir, child);
    if (fs.existsSync(childPath) && fs.statSync(childPath).isDirectory()) {
      record('pass', `src/${child} directory present`);
    } else {
      record('warn', `src/${child} directory missing`, 'Optional but recommended for standard Avenx layouts.');
    }
  }

  const jsconfigPath = path.join(root, '.vscode', 'jsconfig.json');
  if (fs.existsSync(jsconfigPath)) {
    record('pass', '.vscode/jsconfig.json present');
  } else {
    record('warn', '.vscode/jsconfig.json missing', 'Created by `avenx init` for editor path aliases.');
  }

  const indexHtml = path.join(root, 'index.html');
  if (fs.existsSync(indexHtml)) {
    record('pass', 'index.html present');
  } else {
    record('warn', 'index.html missing at project root');
  }

  // --- Git ---
  console.log(`\n${bold('Git')}`);
  try {
    const output = execSync('git status --porcelain', {
      cwd: root,
      encoding: 'utf8',
      stdio: ['ignore', 'pipe', 'ignore'],
    });
    if (output.trim()) {
      record('warn', 'Working tree has uncommitted changes', 'Commit or stash before scaffolding if you want a clean tree.');
    } else {
      record('pass', 'Working tree is clean');
    }
  } catch {
    record('warn', 'Not a git repository (or git is unavailable)');
  }

  // --- Summary ---
  console.log(`\n${bold('Summary')}`);
  console.log(
    `  ${green(`${results.pass} passed`)} · ${yellow(`${results.warn} warnings`)} · ${red(`${results.fail} failed`)}`,
  );
  if (results.fail > 0) {
    console.log(`\n${red('Doctor found issues that should be fixed.')}\n`);
    process.exitCode = 1;
  } else {
    console.log(`\n${green('Environment looks healthy.')}\n`);
  }
}