@docker-doctor/cli 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as runDockerfileRules, c as createComposeLocator, d as discoverProject, f as version, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-CklEIXTX.mjs";
2
+ import { a as runDockerfileRules, c as createComposeLocator, d as discoverProject, f as version, i as runComposeRules, l as parseCompose, n as calculateScore, o as allRules, r as loadConfig, s as findRule, t as toJsonReport, u as parseDockerfile } from "./src-DiEWLJ6g.mjs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import os from "node:os";
@@ -580,8 +580,13 @@ const writeDiagnosticsDirectory = async (diagnostics, report, rootDir) => {
580
580
  ...d,
581
581
  file: sanitizePath(d.file),
582
582
  message: sanitizeMessage(d.message)
583
+ })),
584
+ failures: report.failures.map((failure) => ({
585
+ file: sanitizePath(failure.file),
586
+ message: sanitizeMessage(failure.message)
583
587
  }))
584
588
  };
589
+ await fs.writeFile(path.join(dir, ".gitignore"), "*\n", "utf-8");
585
590
  await fs.writeFile(path.join(dir, "diagnostics.json"), JSON.stringify({
586
591
  note: TRUST_BOUNDARY_NOTE,
587
592
  ...sanitizedReport
@@ -652,6 +657,7 @@ const buildHandoffPayload = (input) => {
652
657
  const ruleWord = groups.length === 1 ? "rule" : "rules";
653
658
  const lines = [
654
659
  `Fix the ${issueCount} Docker Doctor ${issueWord} (${groups.length} ${ruleWord}) in ${sanitizePath(input.projectName)}.`,
660
+ `Every path below is relative to ${sanitizePath(input.projectDir)}. Work from that directory.`,
655
661
  TRUST_BOUNDARY_NOTE,
656
662
  ""
657
663
  ];
@@ -716,8 +722,11 @@ const detectLaunchableAgents = () => {
716
722
 
717
723
  //#endregion
718
724
  //#region src/agents/launch-agent.ts
719
- const launchAgent = (agentId, prompt) => new Promise((resolve) => {
720
- const child = spawn(AGENT_BINARIES[agentId], [...AGENT_AUTO_FLAGS[agentId], prompt], { stdio: "inherit" });
725
+ const launchAgent = (agentId, prompt, cwd) => new Promise((resolve) => {
726
+ const child = spawn(AGENT_BINARIES[agentId], [...AGENT_AUTO_FLAGS[agentId], prompt], {
727
+ cwd,
728
+ stdio: "inherit"
729
+ });
721
730
  child.once("error", () => {
722
731
  resolve(false);
723
732
  });
@@ -771,9 +780,9 @@ const printDiscoveredFiles = (project) => {
771
780
  console.log(` Dockerfile(s): ${project.dockerfiles.length ? project.dockerfiles.map((f) => chalk.cyan(f)).join(", ") : chalk.dim("None")}`);
772
781
  console.log(` Compose file(s): ${project.composeFiles.length ? project.composeFiles.map((f) => chalk.cyan(f)).join(", ") : chalk.dim("None")}`);
773
782
  };
774
- const printDiagnostics = (diagnostics, verbose, fileContents, categoryIssueCounts) => {
783
+ const printDiagnostics = (diagnostics, verbose, fileContents, categoryIssueCounts, unanalyzedCount) => {
775
784
  if (diagnostics.length === 0) {
776
- console.log(`\n${chalk.green.bold("✔ No issues found! Your Docker setup looks healthy.")}`);
785
+ console.log(unanalyzedCount === 0 ? `\n${chalk.green.bold("✔ No issues found! Your Docker setup looks healthy.")}` : `\n${chalk.yellow.bold("⚠ No issues found in the files that could be analyzed.")}`);
777
786
  return;
778
787
  }
779
788
  if (!verbose) {
@@ -790,13 +799,24 @@ const printDiagnostics = (diagnostics, verbose, fileContents, categoryIssueCount
790
799
  console.log(` ${cat} › ${chalk.dim(issueLabel)}`);
791
800
  }
792
801
  console.log(`\n Run ${chalk.cyan("docker-doctor --verbose")} to list every error and warning`);
793
- const ruleCounts = {};
794
- for (const d of diagnostics) ruleCounts[d.rule] = (ruleCounts[d.rule] || 0) + 1;
795
- const migrationRules = Object.entries(ruleCounts).filter(([, count]) => count >= 5);
802
+ const findingsByRule = /* @__PURE__ */ new Map();
803
+ for (const d of diagnostics) {
804
+ const findings = findingsByRule.get(d.rule) ?? {
805
+ count: 0,
806
+ files: /* @__PURE__ */ new Set()
807
+ };
808
+ findings.count += 1;
809
+ findings.files.add(d.file);
810
+ findingsByRule.set(d.rule, findings);
811
+ }
812
+ const migrationRules = [...findingsByRule].filter(([, { count }]) => count >= 5);
796
813
  if (migrationRules.length > 0) {
797
814
  console.log();
798
815
  console.log(` ${chalk.yellow("⚠ Migration-scale change: sample before you sweep")}`);
799
- for (const [rule, count] of migrationRules) console.log(` ${chalk.cyan(rule)} ×${count} across ${count} files`);
816
+ for (const [rule, { count, files }] of migrationRules) {
817
+ const fileWord = files.size === 1 ? "file" : "files";
818
+ console.log(` ${chalk.cyan(rule)} ×${count} across ${files.size} ${fileWord}`);
819
+ }
800
820
  console.log(` Fixing all of them at once is hard to review and prone to`);
801
821
  console.log(` subtle mistakes across the whole repo. Fix a representative`);
802
822
  console.log(` few first and confirm the recipe holds. Then get the code`);
@@ -851,7 +871,7 @@ const getWhaleMascot = (score, border) => {
851
871
  ];
852
872
  };
853
873
  const easeOutCubic = (x) => 1 - (1 - x) ** 3;
854
- const printScoreBox = async (score, label, categoryIssueCounts, warningsCount, errorsCount) => {
874
+ const printScoreBox = async (score, label, warningsCount, errorsCount, unanalyzedCount) => {
855
875
  const { isTTY } = process.stdout;
856
876
  const shouldAnimate = isTTY && !process.env.CI && !process.env.NO_ANIMATION && process.env.TERM !== "dumb" && process.env.NODE_ENV !== "test";
857
877
  let scoreColor = chalk.red.bold;
@@ -895,15 +915,21 @@ const printScoreBox = async (score, label, categoryIssueCounts, warningsCount, e
895
915
  console.log(` ${whaleLines[3]}`);
896
916
  }
897
917
  console.log(`\n ${chalk.dim("────────────────────────────────────────────────────────────")}\n`);
898
- console.log(` Share: ${chalk.cyan(shareUrl)}`);
899
- console.log(` Tell others how you did on socials\n`);
918
+ if (unanalyzedCount === 0) {
919
+ console.log(` Share: ${chalk.cyan(shareUrl)}`);
920
+ console.log(` Tell others how you did on socials\n`);
921
+ } else {
922
+ const fileWord = unanalyzedCount === 1 ? "file" : "files";
923
+ console.log(` ${chalk.yellow(`⚠ Incomplete scan: ${unanalyzedCount} ${fileWord} could not be analyzed.`)}`);
924
+ console.log(` The score covers only the analyzed files.\n`);
925
+ }
900
926
  console.log(` Docs: ${chalk.cyan("https://docker-doctor.vercel.app")}`);
901
927
  console.log(` Learn more about fixing issues, setting up CI/CD, and`);
902
928
  console.log(` configuring rules with a config file\n`);
903
929
  console.log(` GitHub: ${chalk.cyan("https://github.com/PunGrumpy/docker-doctor")}`);
904
930
  console.log(` Report issues and star the repository!`);
905
931
  };
906
- const formatTerminal = async (diagnostics, score, label, project, verbose = false, fileContents = {}) => {
932
+ const formatTerminal = async (diagnostics, score, label, project, verbose = false, fileContents = {}, unanalyzedCount = 0) => {
907
933
  if (verbose) {
908
934
  console.log(`\n${chalk.bold("Docker Doctor Diagnostics")}`);
909
935
  console.log(`=================================`);
@@ -924,8 +950,8 @@ const formatTerminal = async (diagnostics, score, label, project, verbose = fals
924
950
  if (d.severity === "warning") warningsCount += 1;
925
951
  else if (d.severity === "error") errorsCount += 1;
926
952
  }
927
- printDiagnostics(diagnostics, verbose, fileContents, categoryIssueCounts);
928
- await printScoreBox(score, label, categoryIssueCounts, warningsCount, errorsCount);
953
+ printDiagnostics(diagnostics, verbose, fileContents, categoryIssueCounts, unanalyzedCount);
954
+ await printScoreBox(score, label, warningsCount, errorsCount, unanalyzedCount);
929
955
  };
930
956
 
931
957
  //#endregion
@@ -939,8 +965,25 @@ const fileExists = async (filePath) => {
939
965
  return false;
940
966
  }
941
967
  };
968
+ const selfAndAncestors = (dir) => {
969
+ const dirs = [dir];
970
+ let parent = path.dirname(dir);
971
+ while (parent !== dirs.at(-1)) {
972
+ dirs.push(parent);
973
+ parent = path.dirname(parent);
974
+ }
975
+ return dirs;
976
+ };
977
+ const findRepositoryRoot = async (dir) => {
978
+ const candidates = selfAndAncestors(dir);
979
+ const nearest = (await Promise.all(candidates.map((candidate) => fileExists(path.join(candidate, ".git"))))).indexOf(true);
980
+ return nearest === -1 ? null : candidates[nearest];
981
+ };
942
982
  const scaffoldActionWorkflow = async (options) => {
943
- const workflowDir = path.join(options.rootDir, ".github", "workflows");
983
+ const repositoryRoot = await findRepositoryRoot(options.rootDir) ?? options.rootDir;
984
+ const scanDirectory = path.relative(repositoryRoot, options.rootDir).split(path.sep).join("/");
985
+ const directoryInput = scanDirectory === "" ? "" : ` with:\n directory: ${JSON.stringify(scanDirectory)}\n`;
986
+ const workflowDir = path.join(repositoryRoot, ".github", "workflows");
944
987
  const workflowPath = path.join(workflowDir, "docker-doctor.yml");
945
988
  const existing = await fileExists(workflowPath);
946
989
  if (existing && !await options.confirmOverwrite()) return "kept";
@@ -951,13 +994,14 @@ permissions:
951
994
  contents: read
952
995
  pull-requests: write
953
996
  issues: write
997
+ statuses: write
954
998
  jobs:
955
999
  docker-doctor:
956
1000
  runs-on: ubuntu-latest
957
1001
  steps:
958
- - uses: actions/checkout@v5
1002
+ - uses: actions/checkout@v7
959
1003
  - uses: ${options.actionRef}
960
- `;
1004
+ ${directoryInput}`;
961
1005
  await fs.mkdir(workflowDir, { recursive: true });
962
1006
  await fs.writeFile(workflowPath, workflowYaml, "utf-8");
963
1007
  return existing ? "updated" : "created";
@@ -1149,6 +1193,7 @@ const runAgentHandoff = async (context) => {
1149
1193
  await ensureGitignoreEntry(context.rootDir);
1150
1194
  const payload = buildHandoffPayload({
1151
1195
  diagnostics: context.diagnostics,
1196
+ projectDir: context.rootDir,
1152
1197
  projectName: path.basename(context.rootDir)
1153
1198
  });
1154
1199
  if (choice === clipboardIndex) {
@@ -1157,14 +1202,14 @@ const runAgentHandoff = async (context) => {
1157
1202
  return;
1158
1203
  }
1159
1204
  const agentId = launchable[choice];
1160
- if (!await askConfirm(`Launch ${AGENT_BINARIES[agentId]} with ${AGENT_AUTO_FLAGS[agentId].join(" ")}? It will edit files without asking for approval.`)) {
1205
+ if (!await askConfirm(`Launch ${AGENT_BINARIES[agentId]} with ${AGENT_AUTO_FLAGS[agentId].join(" ")} in ${context.rootDir}? It will edit files without asking for approval.`)) {
1161
1206
  printAgentPrompt(payload);
1162
1207
  return;
1163
1208
  }
1164
1209
  const installResult = await installSkillForAgents([agentId], { projectRoot: context.rootDir });
1165
1210
  if (installResult && installResult.installed.length > 0) console.log(`\n ${chalk.green("✔")} Installed the docker-doctor skill for ${agentDisplayName(agentId)}`);
1166
1211
  console.log(`\n Handing off to ${agentDisplayName(agentId)}...\n`);
1167
- if (!await launchAgent(agentId, payload)) {
1212
+ if (!await launchAgent(agentId, payload, context.rootDir)) {
1168
1213
  console.log(` ${chalk.yellow("⚠")} Couldn't launch ${AGENT_BINARIES[agentId]}. Here's the prompt instead:`);
1169
1214
  printAgentPrompt(payload);
1170
1215
  }
@@ -1260,12 +1305,45 @@ const reportScanFailures = (failures) => {
1260
1305
  console.error(`Docker Doctor could not analyze ${failures.length} ${fileWord}; the score below covers only the files that were analyzed.`);
1261
1306
  for (const failure of failures) console.error(` - ${failure.file}: ${flattenMessage(failure.message)}`);
1262
1307
  };
1308
+ const noFilesFoundMessage = (rootDir) => `No Dockerfiles or Compose files found in ${rootDir}`;
1309
+ const printNothingScanned = (rootDir) => {
1310
+ console.log(`\n${chalk.yellow("⚠")} ${noFilesFoundMessage(rootDir)}.`);
1311
+ console.log(` docker-doctor scans files named Dockerfile, Dockerfile.*, *.dockerfile,`);
1312
+ console.log(` compose.yaml, docker-compose.yaml and variants such as compose.prod.yaml`);
1313
+ console.log(` (.yaml or .yml). Check the directory and your ignore.files patterns.`);
1314
+ };
1315
+ const pathExists = async (filePath) => {
1316
+ try {
1317
+ await fs.access(filePath);
1318
+ return true;
1319
+ } catch {
1320
+ return false;
1321
+ }
1322
+ };
1323
+ const resolveConfigPath = async (configOption, rootDir) => {
1324
+ if (!configOption) return;
1325
+ const fromCwd = path.resolve(process.cwd(), configOption);
1326
+ if (await pathExists(fromCwd)) return fromCwd;
1327
+ const fromRoot = path.resolve(rootDir, configOption);
1328
+ if (fromRoot !== fromCwd && await pathExists(fromRoot)) {
1329
+ console.error(`Warning: --config ${configOption} was found relative to the scanned directory, not the current directory. Pass a path relative to the current directory or an absolute path.`);
1330
+ return fromRoot;
1331
+ }
1332
+ return fromCwd;
1333
+ };
1334
+ const findScanTargetProblem = async (rootDir) => {
1335
+ const rootStat = await fs.stat(rootDir).catch(() => null);
1336
+ if (rootStat === null) return `Directory not found: ${rootDir}`;
1337
+ if (!rootStat.isDirectory()) return `${rootDir} is not a directory. Pass the directory that contains your Dockerfile or Compose file.`;
1338
+ return null;
1339
+ };
1263
1340
  const program = new Command();
1264
1341
  program.name("docker-doctor").description("Static analysis for Dockerfile and Docker Compose files").version(version, "-V, --version", "display the version number");
1265
1342
  program.argument("[dir]", "directory to scan", ".").option("-v, --verbose", "show verbose diagnostics description", false).option("-s, --score", "only output numeric health score", false).option("-j, --json", "output results as JSON report", false).option("-c, --config <path>", "custom config file path").action(async (dir, options) => {
1266
1343
  const isSilent = options.score || options.json;
1344
+ const showProgress = Boolean(process.stdout.isTTY) && !isSilent;
1267
1345
  const restoreCursor = () => {
1268
- if (process.stdout.isTTY && !isSilent) process.stdout.write("\x1B[?25h");
1346
+ if (showProgress) process.stdout.write("\x1B[?25h");
1269
1347
  };
1270
1348
  process.on("exit", restoreCursor);
1271
1349
  process.once("SIGINT", () => {
@@ -1278,6 +1356,12 @@ program.argument("[dir]", "directory to scan", ".").option("-v, --verbose", "sho
1278
1356
  });
1279
1357
  try {
1280
1358
  const rootDir = path.resolve(dir);
1359
+ const scanTargetProblem = await findScanTargetProblem(rootDir);
1360
+ if (scanTargetProblem !== null) {
1361
+ console.error(`Error: ${scanTargetProblem}`);
1362
+ process.exitCode = 1;
1363
+ return;
1364
+ }
1281
1365
  const startTime = Date.now();
1282
1366
  let statusText = "Discovering workspace...";
1283
1367
  const spinnerFrames = [
@@ -1297,7 +1381,7 @@ program.argument("[dir]", "directory to scan", ".").option("-v, --verbose", "sho
1297
1381
  const setStatus = (text) => {
1298
1382
  statusText = text;
1299
1383
  };
1300
- if (process.stdout.isTTY && !isSilent) {
1384
+ if (showProgress) {
1301
1385
  process.stdout.write("\x1B[?25l");
1302
1386
  process.stdout.write(`${chalk.cyan(spinnerFrames[0])} ${statusText}`);
1303
1387
  spinnerInterval = setInterval(() => {
@@ -1306,19 +1390,26 @@ program.argument("[dir]", "directory to scan", ".").option("-v, --verbose", "sho
1306
1390
  }, 80);
1307
1391
  }
1308
1392
  try {
1309
- if (process.stdout.isTTY && !isSilent) await setTimeout(150);
1393
+ if (showProgress) await setTimeout(150);
1310
1394
  setStatus("Loading configuration...");
1311
- const config = await loadConfig(rootDir, options.config, (message) => {
1395
+ const configPath = await resolveConfigPath(options.config, rootDir);
1396
+ const config = await loadConfig(rootDir, configPath, (message) => {
1312
1397
  console.error(`Warning: ${message}`);
1313
1398
  });
1314
1399
  setStatus("Scanning workspace files...");
1315
- const project = await discoverProject(rootDir, { ignoreFiles: config.ignore?.files });
1400
+ const project = await discoverProject(rootDir, {
1401
+ ignoreFiles: config.ignore?.files,
1402
+ onSkippedDirectory: (skippedDir) => {
1403
+ console.error(`Warning: skipped unreadable directory ${skippedDir} (permission denied)`);
1404
+ }
1405
+ });
1316
1406
  const fileContents = {};
1317
1407
  const projectFilesList = [
1318
1408
  ...project.dockerfiles,
1319
1409
  ...project.composeFiles,
1320
1410
  ...project.dockerignores || []
1321
1411
  ];
1412
+ const scannedFileCount = project.dockerfiles.length + project.composeFiles.length;
1322
1413
  const { diagnostics, failures } = await runRulesEngine(rootDir, project, config.rules, config.categories, projectFilesList, fileContents, options, setStatus);
1323
1414
  const { score, label } = calculateScore(diagnostics);
1324
1415
  const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
@@ -1328,25 +1419,33 @@ program.argument("[dir]", "directory to scan", ".").option("-v, --verbose", "sho
1328
1419
  spinnerInterval = null;
1329
1420
  process.stdout.write("\r\x1B[K\x1B[?25h");
1330
1421
  }
1331
- if (process.stdout.isTTY && !isSilent) console.log(`${chalk.green("✔")} Scanned ${projectFilesList.length} files in ${duration}s [~${concurrency} workers]`);
1422
+ if (showProgress) console.log(`${chalk.green("✔")} Scanned ${scannedFileCount} files in ${duration}s [~${concurrency} workers]`);
1332
1423
  reportScanFailures(failures);
1333
1424
  const scanIncomplete = failures.length > 0;
1425
+ if (scannedFileCount === 0) {
1426
+ if (isSilent) console.error(`Warning: ${noFilesFoundMessage(rootDir)}`);
1427
+ else {
1428
+ printNothingScanned(rootDir);
1429
+ process.exitCode = 0;
1430
+ return;
1431
+ }
1432
+ }
1334
1433
  const hasErrors = diagnostics.some((d) => d.severity === "error");
1335
1434
  if (options.score) {
1336
1435
  console.log(score);
1337
1436
  process.exitCode = scanExitCode(scanIncomplete, hasErrors);
1338
1437
  return;
1339
1438
  } else if (options.json) {
1340
- const report = toJsonReport(diagnostics, score, label, project);
1439
+ const report = toJsonReport(diagnostics, score, label, project, failures);
1341
1440
  console.log(JSON.stringify(report, null, 2));
1342
1441
  process.exitCode = scanExitCode(scanIncomplete, hasErrors);
1343
1442
  return;
1344
1443
  }
1345
- await formatTerminal(diagnostics, score, label, project, options.verbose, fileContents);
1444
+ await formatTerminal(diagnostics, score, label, project, options.verbose, fileContents, failures.length);
1346
1445
  let wizardOk = true;
1347
1446
  if (process.stdout.isTTY && process.stdin.isTTY) wizardOk = await runInteractiveWizard({
1348
1447
  diagnostics,
1349
- report: toJsonReport(diagnostics, score, label, project),
1448
+ report: toJsonReport(diagnostics, score, label, project, failures),
1350
1449
  rootDir
1351
1450
  });
1352
1451
  const exitCode = scanExitCode(scanIncomplete, hasErrors);
@@ -1434,7 +1533,7 @@ rules.command("list").description("list all available rules").action(() => {
1434
1533
  }
1435
1534
  });
1436
1535
  rules.command("explain <rule>").description("explain a specific rule in detail").action((ruleKey) => {
1437
- const rule = findRule(ruleKey);
1536
+ const rule = findRule(ruleKey) ?? findRule(`docker-doctor/${ruleKey}`);
1438
1537
  if (!rule) {
1439
1538
  console.error(`Rule '${ruleKey}' not found.`);
1440
1539
  process.exitCode = 1;