@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/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  Your Dockerfiles are probably wrong. Docker Doctor finds out why.
16
16
 
17
- Docker Doctor is an opinionated static analysis tool for Dockerfile and Docker Compose files. It scans your project, runs 31 rules across security, performance, best practices, Compose, and image size — then gives you a health score and fix guidance.
17
+ Docker Doctor is an opinionated static analysis tool for Dockerfile and Docker Compose files. It scans your project, runs 33 rules across security, performance, best practices, Compose, and image size — then gives you a health score and fix guidance.
18
18
 
19
19
  Works with any project that uses Docker.
20
20
 
package/dist/cli.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const require_src = require('./src-D4rVJg36.cjs');
2
+ const require_src = require('./src-C7XK6Kp-.cjs');
3
3
  let node_fs_promises = require("node:fs/promises");
4
4
  node_fs_promises = require_src.__toESM(node_fs_promises, 1);
5
5
  let node_path = require("node:path");
@@ -587,8 +587,13 @@ const writeDiagnosticsDirectory = async (diagnostics, report, rootDir) => {
587
587
  ...d,
588
588
  file: sanitizePath(d.file),
589
589
  message: sanitizeMessage(d.message)
590
+ })),
591
+ failures: report.failures.map((failure) => ({
592
+ file: sanitizePath(failure.file),
593
+ message: sanitizeMessage(failure.message)
590
594
  }))
591
595
  };
596
+ await node_fs_promises.default.writeFile(node_path.default.join(dir, ".gitignore"), "*\n", "utf-8");
592
597
  await node_fs_promises.default.writeFile(node_path.default.join(dir, "diagnostics.json"), JSON.stringify({
593
598
  note: TRUST_BOUNDARY_NOTE,
594
599
  ...sanitizedReport
@@ -659,6 +664,7 @@ const buildHandoffPayload = (input) => {
659
664
  const ruleWord = groups.length === 1 ? "rule" : "rules";
660
665
  const lines = [
661
666
  `Fix the ${issueCount} Docker Doctor ${issueWord} (${groups.length} ${ruleWord}) in ${sanitizePath(input.projectName)}.`,
667
+ `Every path below is relative to ${sanitizePath(input.projectDir)}. Work from that directory.`,
662
668
  TRUST_BOUNDARY_NOTE,
663
669
  ""
664
670
  ];
@@ -723,8 +729,11 @@ const detectLaunchableAgents = () => {
723
729
 
724
730
  //#endregion
725
731
  //#region src/agents/launch-agent.ts
726
- const launchAgent = (agentId, prompt) => new Promise((resolve) => {
727
- const child = (0, node_child_process.spawn)(AGENT_BINARIES[agentId], [...AGENT_AUTO_FLAGS[agentId], prompt], { stdio: "inherit" });
732
+ const launchAgent = (agentId, prompt, cwd) => new Promise((resolve) => {
733
+ const child = (0, node_child_process.spawn)(AGENT_BINARIES[agentId], [...AGENT_AUTO_FLAGS[agentId], prompt], {
734
+ cwd,
735
+ stdio: "inherit"
736
+ });
728
737
  child.once("error", () => {
729
738
  resolve(false);
730
739
  });
@@ -778,9 +787,9 @@ const printDiscoveredFiles = (project) => {
778
787
  console.log(` Dockerfile(s): ${project.dockerfiles.length ? project.dockerfiles.map((f) => chalk.cyan(f)).join(", ") : chalk.dim("None")}`);
779
788
  console.log(` Compose file(s): ${project.composeFiles.length ? project.composeFiles.map((f) => chalk.cyan(f)).join(", ") : chalk.dim("None")}`);
780
789
  };
781
- const printDiagnostics = (diagnostics, verbose, fileContents, categoryIssueCounts) => {
790
+ const printDiagnostics = (diagnostics, verbose, fileContents, categoryIssueCounts, unanalyzedCount) => {
782
791
  if (diagnostics.length === 0) {
783
- console.log(`\n${chalk.green.bold("✔ No issues found! Your Docker setup looks healthy.")}`);
792
+ 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.")}`);
784
793
  return;
785
794
  }
786
795
  if (!verbose) {
@@ -797,13 +806,24 @@ const printDiagnostics = (diagnostics, verbose, fileContents, categoryIssueCount
797
806
  console.log(` ${cat} › ${chalk.dim(issueLabel)}`);
798
807
  }
799
808
  console.log(`\n Run ${chalk.cyan("docker-doctor --verbose")} to list every error and warning`);
800
- const ruleCounts = {};
801
- for (const d of diagnostics) ruleCounts[d.rule] = (ruleCounts[d.rule] || 0) + 1;
802
- const migrationRules = Object.entries(ruleCounts).filter(([, count]) => count >= 5);
809
+ const findingsByRule = /* @__PURE__ */ new Map();
810
+ for (const d of diagnostics) {
811
+ const findings = findingsByRule.get(d.rule) ?? {
812
+ count: 0,
813
+ files: /* @__PURE__ */ new Set()
814
+ };
815
+ findings.count += 1;
816
+ findings.files.add(d.file);
817
+ findingsByRule.set(d.rule, findings);
818
+ }
819
+ const migrationRules = [...findingsByRule].filter(([, { count }]) => count >= 5);
803
820
  if (migrationRules.length > 0) {
804
821
  console.log();
805
822
  console.log(` ${chalk.yellow("⚠ Migration-scale change: sample before you sweep")}`);
806
- for (const [rule, count] of migrationRules) console.log(` ${chalk.cyan(rule)} ×${count} across ${count} files`);
823
+ for (const [rule, { count, files }] of migrationRules) {
824
+ const fileWord = files.size === 1 ? "file" : "files";
825
+ console.log(` ${chalk.cyan(rule)} ×${count} across ${files.size} ${fileWord}`);
826
+ }
807
827
  console.log(` Fixing all of them at once is hard to review and prone to`);
808
828
  console.log(` subtle mistakes across the whole repo. Fix a representative`);
809
829
  console.log(` few first and confirm the recipe holds. Then get the code`);
@@ -858,7 +878,7 @@ const getWhaleMascot = (score, border) => {
858
878
  ];
859
879
  };
860
880
  const easeOutCubic = (x) => 1 - (1 - x) ** 3;
861
- const printScoreBox = async (score, label, categoryIssueCounts, warningsCount, errorsCount) => {
881
+ const printScoreBox = async (score, label, warningsCount, errorsCount, unanalyzedCount) => {
862
882
  const { isTTY } = process.stdout;
863
883
  const shouldAnimate = isTTY && !process.env.CI && !process.env.NO_ANIMATION && process.env.TERM !== "dumb" && process.env.NODE_ENV !== "test";
864
884
  let scoreColor = chalk.red.bold;
@@ -902,15 +922,21 @@ const printScoreBox = async (score, label, categoryIssueCounts, warningsCount, e
902
922
  console.log(` ${whaleLines[3]}`);
903
923
  }
904
924
  console.log(`\n ${chalk.dim("────────────────────────────────────────────────────────────")}\n`);
905
- console.log(` Share: ${chalk.cyan(shareUrl)}`);
906
- console.log(` Tell others how you did on socials\n`);
925
+ if (unanalyzedCount === 0) {
926
+ console.log(` Share: ${chalk.cyan(shareUrl)}`);
927
+ console.log(` Tell others how you did on socials\n`);
928
+ } else {
929
+ const fileWord = unanalyzedCount === 1 ? "file" : "files";
930
+ console.log(` ${chalk.yellow(`⚠ Incomplete scan: ${unanalyzedCount} ${fileWord} could not be analyzed.`)}`);
931
+ console.log(` The score covers only the analyzed files.\n`);
932
+ }
907
933
  console.log(` Docs: ${chalk.cyan("https://docker-doctor.vercel.app")}`);
908
934
  console.log(` Learn more about fixing issues, setting up CI/CD, and`);
909
935
  console.log(` configuring rules with a config file\n`);
910
936
  console.log(` GitHub: ${chalk.cyan("https://github.com/PunGrumpy/docker-doctor")}`);
911
937
  console.log(` Report issues and star the repository!`);
912
938
  };
913
- const formatTerminal = async (diagnostics, score, label, project, verbose = false, fileContents = {}) => {
939
+ const formatTerminal = async (diagnostics, score, label, project, verbose = false, fileContents = {}, unanalyzedCount = 0) => {
914
940
  if (verbose) {
915
941
  console.log(`\n${chalk.bold("Docker Doctor Diagnostics")}`);
916
942
  console.log(`=================================`);
@@ -931,8 +957,8 @@ const formatTerminal = async (diagnostics, score, label, project, verbose = fals
931
957
  if (d.severity === "warning") warningsCount += 1;
932
958
  else if (d.severity === "error") errorsCount += 1;
933
959
  }
934
- printDiagnostics(diagnostics, verbose, fileContents, categoryIssueCounts);
935
- await printScoreBox(score, label, categoryIssueCounts, warningsCount, errorsCount);
960
+ printDiagnostics(diagnostics, verbose, fileContents, categoryIssueCounts, unanalyzedCount);
961
+ await printScoreBox(score, label, warningsCount, errorsCount, unanalyzedCount);
936
962
  };
937
963
 
938
964
  //#endregion
@@ -946,8 +972,25 @@ const fileExists = async (filePath) => {
946
972
  return false;
947
973
  }
948
974
  };
975
+ const selfAndAncestors = (dir) => {
976
+ const dirs = [dir];
977
+ let parent = node_path.default.dirname(dir);
978
+ while (parent !== dirs.at(-1)) {
979
+ dirs.push(parent);
980
+ parent = node_path.default.dirname(parent);
981
+ }
982
+ return dirs;
983
+ };
984
+ const findRepositoryRoot = async (dir) => {
985
+ const candidates = selfAndAncestors(dir);
986
+ const nearest = (await Promise.all(candidates.map((candidate) => fileExists(node_path.default.join(candidate, ".git"))))).indexOf(true);
987
+ return nearest === -1 ? null : candidates[nearest];
988
+ };
949
989
  const scaffoldActionWorkflow = async (options) => {
950
- const workflowDir = node_path.default.join(options.rootDir, ".github", "workflows");
990
+ const repositoryRoot = await findRepositoryRoot(options.rootDir) ?? options.rootDir;
991
+ const scanDirectory = node_path.default.relative(repositoryRoot, options.rootDir).split(node_path.default.sep).join("/");
992
+ const directoryInput = scanDirectory === "" ? "" : ` with:\n directory: ${JSON.stringify(scanDirectory)}\n`;
993
+ const workflowDir = node_path.default.join(repositoryRoot, ".github", "workflows");
951
994
  const workflowPath = node_path.default.join(workflowDir, "docker-doctor.yml");
952
995
  const existing = await fileExists(workflowPath);
953
996
  if (existing && !await options.confirmOverwrite()) return "kept";
@@ -958,13 +1001,14 @@ permissions:
958
1001
  contents: read
959
1002
  pull-requests: write
960
1003
  issues: write
1004
+ statuses: write
961
1005
  jobs:
962
1006
  docker-doctor:
963
1007
  runs-on: ubuntu-latest
964
1008
  steps:
965
- - uses: actions/checkout@v5
1009
+ - uses: actions/checkout@v7
966
1010
  - uses: ${options.actionRef}
967
- `;
1011
+ ${directoryInput}`;
968
1012
  await node_fs_promises.default.mkdir(workflowDir, { recursive: true });
969
1013
  await node_fs_promises.default.writeFile(workflowPath, workflowYaml, "utf-8");
970
1014
  return existing ? "updated" : "created";
@@ -1156,6 +1200,7 @@ const runAgentHandoff = async (context) => {
1156
1200
  await ensureGitignoreEntry(context.rootDir);
1157
1201
  const payload = buildHandoffPayload({
1158
1202
  diagnostics: context.diagnostics,
1203
+ projectDir: context.rootDir,
1159
1204
  projectName: node_path.default.basename(context.rootDir)
1160
1205
  });
1161
1206
  if (choice === clipboardIndex) {
@@ -1164,14 +1209,14 @@ const runAgentHandoff = async (context) => {
1164
1209
  return;
1165
1210
  }
1166
1211
  const agentId = launchable[choice];
1167
- if (!await askConfirm(`Launch ${AGENT_BINARIES[agentId]} with ${AGENT_AUTO_FLAGS[agentId].join(" ")}? It will edit files without asking for approval.`)) {
1212
+ if (!await askConfirm(`Launch ${AGENT_BINARIES[agentId]} with ${AGENT_AUTO_FLAGS[agentId].join(" ")} in ${context.rootDir}? It will edit files without asking for approval.`)) {
1168
1213
  printAgentPrompt(payload);
1169
1214
  return;
1170
1215
  }
1171
1216
  const installResult = await installSkillForAgents([agentId], { projectRoot: context.rootDir });
1172
1217
  if (installResult && installResult.installed.length > 0) console.log(`\n ${chalk.green("✔")} Installed the docker-doctor skill for ${agentDisplayName(agentId)}`);
1173
1218
  console.log(`\n Handing off to ${agentDisplayName(agentId)}...\n`);
1174
- if (!await launchAgent(agentId, payload)) {
1219
+ if (!await launchAgent(agentId, payload, context.rootDir)) {
1175
1220
  console.log(` ${chalk.yellow("⚠")} Couldn't launch ${AGENT_BINARIES[agentId]}. Here's the prompt instead:`);
1176
1221
  printAgentPrompt(payload);
1177
1222
  }
@@ -1267,12 +1312,45 @@ const reportScanFailures = (failures) => {
1267
1312
  console.error(`Docker Doctor could not analyze ${failures.length} ${fileWord}; the score below covers only the files that were analyzed.`);
1268
1313
  for (const failure of failures) console.error(` - ${failure.file}: ${flattenMessage(failure.message)}`);
1269
1314
  };
1315
+ const noFilesFoundMessage = (rootDir) => `No Dockerfiles or Compose files found in ${rootDir}`;
1316
+ const printNothingScanned = (rootDir) => {
1317
+ console.log(`\n${chalk.yellow("⚠")} ${noFilesFoundMessage(rootDir)}.`);
1318
+ console.log(` docker-doctor scans files named Dockerfile, Dockerfile.*, *.dockerfile,`);
1319
+ console.log(` compose.yaml, docker-compose.yaml and variants such as compose.prod.yaml`);
1320
+ console.log(` (.yaml or .yml). Check the directory and your ignore.files patterns.`);
1321
+ };
1322
+ const pathExists = async (filePath) => {
1323
+ try {
1324
+ await node_fs_promises.default.access(filePath);
1325
+ return true;
1326
+ } catch {
1327
+ return false;
1328
+ }
1329
+ };
1330
+ const resolveConfigPath = async (configOption, rootDir) => {
1331
+ if (!configOption) return;
1332
+ const fromCwd = node_path.default.resolve(process.cwd(), configOption);
1333
+ if (await pathExists(fromCwd)) return fromCwd;
1334
+ const fromRoot = node_path.default.resolve(rootDir, configOption);
1335
+ if (fromRoot !== fromCwd && await pathExists(fromRoot)) {
1336
+ 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.`);
1337
+ return fromRoot;
1338
+ }
1339
+ return fromCwd;
1340
+ };
1341
+ const findScanTargetProblem = async (rootDir) => {
1342
+ const rootStat = await node_fs_promises.default.stat(rootDir).catch(() => null);
1343
+ if (rootStat === null) return `Directory not found: ${rootDir}`;
1344
+ if (!rootStat.isDirectory()) return `${rootDir} is not a directory. Pass the directory that contains your Dockerfile or Compose file.`;
1345
+ return null;
1346
+ };
1270
1347
  const program = new commander.Command();
1271
1348
  program.name("docker-doctor").description("Static analysis for Dockerfile and Docker Compose files").version(require_src.version, "-V, --version", "display the version number");
1272
1349
  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) => {
1273
1350
  const isSilent = options.score || options.json;
1351
+ const showProgress = Boolean(process.stdout.isTTY) && !isSilent;
1274
1352
  const restoreCursor = () => {
1275
- if (process.stdout.isTTY && !isSilent) process.stdout.write("\x1B[?25h");
1353
+ if (showProgress) process.stdout.write("\x1B[?25h");
1276
1354
  };
1277
1355
  process.on("exit", restoreCursor);
1278
1356
  process.once("SIGINT", () => {
@@ -1285,6 +1363,12 @@ program.argument("[dir]", "directory to scan", ".").option("-v, --verbose", "sho
1285
1363
  });
1286
1364
  try {
1287
1365
  const rootDir = node_path.default.resolve(dir);
1366
+ const scanTargetProblem = await findScanTargetProblem(rootDir);
1367
+ if (scanTargetProblem !== null) {
1368
+ console.error(`Error: ${scanTargetProblem}`);
1369
+ process.exitCode = 1;
1370
+ return;
1371
+ }
1288
1372
  const startTime = Date.now();
1289
1373
  let statusText = "Discovering workspace...";
1290
1374
  const spinnerFrames = [
@@ -1304,7 +1388,7 @@ program.argument("[dir]", "directory to scan", ".").option("-v, --verbose", "sho
1304
1388
  const setStatus = (text) => {
1305
1389
  statusText = text;
1306
1390
  };
1307
- if (process.stdout.isTTY && !isSilent) {
1391
+ if (showProgress) {
1308
1392
  process.stdout.write("\x1B[?25l");
1309
1393
  process.stdout.write(`${chalk.cyan(spinnerFrames[0])} ${statusText}`);
1310
1394
  spinnerInterval = setInterval(() => {
@@ -1313,19 +1397,26 @@ program.argument("[dir]", "directory to scan", ".").option("-v, --verbose", "sho
1313
1397
  }, 80);
1314
1398
  }
1315
1399
  try {
1316
- if (process.stdout.isTTY && !isSilent) await (0, node_timers_promises.setTimeout)(150);
1400
+ if (showProgress) await (0, node_timers_promises.setTimeout)(150);
1317
1401
  setStatus("Loading configuration...");
1318
- const config = await require_src.loadConfig(rootDir, options.config, (message) => {
1402
+ const configPath = await resolveConfigPath(options.config, rootDir);
1403
+ const config = await require_src.loadConfig(rootDir, configPath, (message) => {
1319
1404
  console.error(`Warning: ${message}`);
1320
1405
  });
1321
1406
  setStatus("Scanning workspace files...");
1322
- const project = await require_src.discoverProject(rootDir, { ignoreFiles: config.ignore?.files });
1407
+ const project = await require_src.discoverProject(rootDir, {
1408
+ ignoreFiles: config.ignore?.files,
1409
+ onSkippedDirectory: (skippedDir) => {
1410
+ console.error(`Warning: skipped unreadable directory ${skippedDir} (permission denied)`);
1411
+ }
1412
+ });
1323
1413
  const fileContents = {};
1324
1414
  const projectFilesList = [
1325
1415
  ...project.dockerfiles,
1326
1416
  ...project.composeFiles,
1327
1417
  ...project.dockerignores || []
1328
1418
  ];
1419
+ const scannedFileCount = project.dockerfiles.length + project.composeFiles.length;
1329
1420
  const { diagnostics, failures } = await runRulesEngine(rootDir, project, config.rules, config.categories, projectFilesList, fileContents, options, setStatus);
1330
1421
  const { score, label } = require_src.calculateScore(diagnostics);
1331
1422
  const duration = ((Date.now() - startTime) / 1e3).toFixed(1);
@@ -1335,25 +1426,33 @@ program.argument("[dir]", "directory to scan", ".").option("-v, --verbose", "sho
1335
1426
  spinnerInterval = null;
1336
1427
  process.stdout.write("\r\x1B[K\x1B[?25h");
1337
1428
  }
1338
- if (process.stdout.isTTY && !isSilent) console.log(`${chalk.green("✔")} Scanned ${projectFilesList.length} files in ${duration}s [~${concurrency} workers]`);
1429
+ if (showProgress) console.log(`${chalk.green("✔")} Scanned ${scannedFileCount} files in ${duration}s [~${concurrency} workers]`);
1339
1430
  reportScanFailures(failures);
1340
1431
  const scanIncomplete = failures.length > 0;
1432
+ if (scannedFileCount === 0) {
1433
+ if (isSilent) console.error(`Warning: ${noFilesFoundMessage(rootDir)}`);
1434
+ else {
1435
+ printNothingScanned(rootDir);
1436
+ process.exitCode = 0;
1437
+ return;
1438
+ }
1439
+ }
1341
1440
  const hasErrors = diagnostics.some((d) => d.severity === "error");
1342
1441
  if (options.score) {
1343
1442
  console.log(score);
1344
1443
  process.exitCode = scanExitCode(scanIncomplete, hasErrors);
1345
1444
  return;
1346
1445
  } else if (options.json) {
1347
- const report = require_src.toJsonReport(diagnostics, score, label, project);
1446
+ const report = require_src.toJsonReport(diagnostics, score, label, project, failures);
1348
1447
  console.log(JSON.stringify(report, null, 2));
1349
1448
  process.exitCode = scanExitCode(scanIncomplete, hasErrors);
1350
1449
  return;
1351
1450
  }
1352
- await formatTerminal(diagnostics, score, label, project, options.verbose, fileContents);
1451
+ await formatTerminal(diagnostics, score, label, project, options.verbose, fileContents, failures.length);
1353
1452
  let wizardOk = true;
1354
1453
  if (process.stdout.isTTY && process.stdin.isTTY) wizardOk = await runInteractiveWizard({
1355
1454
  diagnostics,
1356
- report: require_src.toJsonReport(diagnostics, score, label, project),
1455
+ report: require_src.toJsonReport(diagnostics, score, label, project, failures),
1357
1456
  rootDir
1358
1457
  });
1359
1458
  const exitCode = scanExitCode(scanIncomplete, hasErrors);
@@ -1441,7 +1540,7 @@ rules.command("list").description("list all available rules").action(() => {
1441
1540
  }
1442
1541
  });
1443
1542
  rules.command("explain <rule>").description("explain a specific rule in detail").action((ruleKey) => {
1444
- const rule = require_src.findRule(ruleKey);
1543
+ const rule = require_src.findRule(ruleKey) ?? require_src.findRule(`docker-doctor/${ruleKey}`);
1445
1544
  if (!rule) {
1446
1545
  console.error(`Rule '${ruleKey}' not found.`);
1447
1546
  process.exitCode = 1;