itworksbut 0.5.0 → 0.7.0

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.
@@ -0,0 +1,53 @@
1
+ export const CHECK_STATUSES = ["pass", "warn", "fail", "skip"];
2
+
3
+ export function normalizeCheckResult(check, value = {}, findings = []) {
4
+ const status = normalizeStatus(value.status || deriveStatusFromFindings(findings));
5
+ const details = Array.isArray(value.details) ? value.details : detailsFromFindings(findings);
6
+
7
+ return {
8
+ id: value.id || check.id,
9
+ title: value.title || check.title,
10
+ category: value.category || check.category,
11
+ status,
12
+ summary: value.summary || defaultSummary(status, findings),
13
+ details,
14
+ metadata: value.metadata || undefined
15
+ };
16
+ }
17
+
18
+ export function deriveStatusFromFindings(findings = []) {
19
+ if (!findings.length) return "pass";
20
+ if (findings.some((finding) => finding.severity === "critical" || finding.severity === "high")) return "fail";
21
+ return "warn";
22
+ }
23
+
24
+ export function countByStatus(checks = []) {
25
+ return Object.fromEntries(
26
+ CHECK_STATUSES.map((status) => [status, checks.filter((check) => check.status === status).length])
27
+ );
28
+ }
29
+
30
+ function normalizeStatus(value) {
31
+ const normalized = String(value || "pass").toLowerCase();
32
+ return CHECK_STATUSES.includes(normalized) ? normalized : "fail";
33
+ }
34
+
35
+ function defaultSummary(status, findings) {
36
+ if (status === "pass") return "No issues found.";
37
+ if (status === "skip") return "Skipped.";
38
+ if (!findings.length) return status === "fail" ? "Check failed." : "Check needs review.";
39
+
40
+ const count = findings.length;
41
+ const noun = count === 1 ? "finding" : "findings";
42
+ return `${count} ${noun} reported.`;
43
+ }
44
+
45
+ function detailsFromFindings(findings) {
46
+ return findings.map((finding) => ({
47
+ message: finding.message,
48
+ file: finding.file,
49
+ line: finding.line,
50
+ severity: finding.severity,
51
+ recommendation: finding.recommendation
52
+ }));
53
+ }
@@ -1,34 +1,64 @@
1
1
  import checks from "../checks/index.js";
2
2
  import { createContext } from "./context.js";
3
+ import { normalizeCheckResult } from "./checkResults.js";
3
4
  import { normalizeFinding, severityRank } from "./findings.js";
4
5
  import { packageInfo } from "./packageInfo.js";
5
6
 
6
7
  export async function scanProject(options = {}) {
7
8
  const startedAt = new Date();
8
9
  const context = await createContext(options);
10
+ const includedCategories = normalizeFilter(options.categories);
9
11
  const findings = [];
12
+ const checkResults = [];
10
13
  const warnings = [];
11
14
 
12
15
  for (const check of checks) {
13
- if (context.config.checks[check.id] === false) continue;
16
+ if (includedCategories && !includedCategories.has(check.category)) continue;
17
+
18
+ if (context.config.checks[check.id] === false) {
19
+ checkResults.push(normalizeCheckResult(check, {
20
+ status: "skip",
21
+ summary: "Disabled by configuration."
22
+ }));
23
+ continue;
24
+ }
14
25
 
15
26
  try {
16
- const checkFindings = await check.run(context);
27
+ const rawResult = await check.run(context);
28
+ const checkFindings = Array.isArray(rawResult) ? rawResult : rawResult?.findings;
29
+ const explicitCheckResult = Array.isArray(rawResult) ? null : rawResult?.result || rawResult?.checkResult;
30
+
17
31
  if (!Array.isArray(checkFindings)) {
18
32
  warnings.push({
19
33
  checkId: check.id,
20
34
  message: "Check returned a non-array result and was ignored."
21
35
  });
36
+ checkResults.push(normalizeCheckResult(check, {
37
+ status: "fail",
38
+ summary: "Check returned an invalid result.",
39
+ details: [{ message: "Check returned a non-array result and was ignored." }]
40
+ }));
22
41
  continue;
23
42
  }
43
+
44
+ const normalizedFindings = [];
24
45
  for (const finding of checkFindings) {
25
- findings.push(normalizeFinding(check, finding));
46
+ const normalizedFinding = normalizeFinding(check, finding);
47
+ normalizedFindings.push(normalizedFinding);
48
+ findings.push(normalizedFinding);
26
49
  }
50
+ checkResults.push(normalizeCheckResult(check, explicitCheckResult || {}, normalizedFindings));
27
51
  } catch (error) {
52
+ const message = error instanceof Error ? error.message : String(error);
28
53
  warnings.push({
29
54
  checkId: check.id,
30
- message: error instanceof Error ? error.message : String(error)
55
+ message
31
56
  });
57
+ checkResults.push(normalizeCheckResult(check, {
58
+ status: "fail",
59
+ summary: message,
60
+ details: [{ message }]
61
+ }));
32
62
  }
33
63
  }
34
64
 
@@ -40,12 +70,15 @@ export async function scanProject(options = {}) {
40
70
 
41
71
  return {
42
72
  findings,
73
+ checks: checkResults,
43
74
  warnings,
44
75
  config: context.config,
45
76
  meta: {
46
77
  tool: "ItWorksBut",
47
78
  version: packageInfo.version,
48
79
  rootPath: context.rootPath,
80
+ packageName: context.packageJson?.name,
81
+ categories: includedCategories ? [...includedCategories] : undefined,
49
82
  packageManager: context.packageManager,
50
83
  gitAvailable: context.gitAvailable,
51
84
  filesScanned: context.allFiles.length,
@@ -55,3 +88,8 @@ export async function scanProject(options = {}) {
55
88
  }
56
89
  };
57
90
  }
91
+
92
+ function normalizeFilter(values) {
93
+ if (!Array.isArray(values) || values.length === 0) return null;
94
+ return new Set(values.map((value) => String(value).trim()).filter(Boolean));
95
+ }
@@ -12,15 +12,17 @@ import {
12
12
 
13
13
  export function reportConsole(result, options = {}) {
14
14
  const { findings, warnings, config, meta } = result;
15
+ const checks = result.checks || [];
15
16
  const counts = countBySeverity(findings);
16
17
  const colors = getChalk(options);
17
18
  const rich = isFancyOutputEnabled(options);
19
+ const hasReviewCheck = checks.some(check => check.status === 'warn' || check.status === 'fail');
18
20
 
19
21
  if (!options.quiet && !rich) {
20
22
  process.stdout.write(`${colors.bold('ItWorksBut receipts')}\n\n`);
21
23
  }
22
24
 
23
- if (!options.quiet && findings.length === 0) {
25
+ if (!options.quiet && findings.length === 0 && !hasReviewCheck) {
24
26
  process.stdout.write(
25
27
  `${colors.green ? colors.green('Suspiciously clean. No findings.') : 'Suspiciously clean. No findings.'}\n\n`,
26
28
  );
@@ -36,6 +38,10 @@ export function reportConsole(result, options = {}) {
36
38
  }
37
39
  }
38
40
 
41
+ if (!options.quiet) {
42
+ writeOutdatedPackagesCheck(checks, options);
43
+ }
44
+
39
45
  if (options.verbose && warnings.length > 0) {
40
46
  process.stdout.write('WARNINGS\n');
41
47
  for (const warning of warnings) {
@@ -55,6 +61,41 @@ export function reportConsole(result, options = {}) {
55
61
  }
56
62
  }
57
63
 
64
+ function writeOutdatedPackagesCheck(checks, options) {
65
+ const check = checks.find(candidate => candidate.id === 'dependencies.outdated-packages');
66
+ if (!check) return;
67
+
68
+ const colors = getChalk(options);
69
+ const title = colors.bold('Outdated packages');
70
+
71
+ if (check.status === 'pass') {
72
+ process.stdout.write(`${colors.green('✓')} ${title}: ${check.summary}\n\n`);
73
+ return;
74
+ }
75
+
76
+ if (check.status === 'skip') {
77
+ process.stdout.write(`- ${title}: ${check.summary}\n\n`);
78
+ return;
79
+ }
80
+
81
+ if (check.status === 'fail') {
82
+ process.stdout.write(`${colors.red('✖')} ${title}: ${check.summary}\n\n`);
83
+ return;
84
+ }
85
+
86
+ process.stdout.write(`${colors.yellow('⚠')} ${title}: ${check.summary}\n`);
87
+ const packages = check.details.filter(detail => detail?.name).slice(0, 10);
88
+ for (const detail of packages) {
89
+ process.stdout.write(
90
+ ` - ${detail.name}: ${detail.current} → ${detail.wanted} wanted, ${detail.latest} latest\n`,
91
+ );
92
+ }
93
+ if (check.details.length > packages.length) {
94
+ process.stdout.write(` - and ${check.details.length - packages.length} more\n`);
95
+ }
96
+ process.stdout.write('\n');
97
+ }
98
+
58
99
  function writeFinding(finding, options) {
59
100
  const colors = getChalk(options);
60
101
  const severity = formatSeverity(finding.severity, options);
@@ -1,4 +1,5 @@
1
1
  import { countBySeverity, getExitCode } from "../core/findings.js";
2
+ import { countByStatus } from "../core/checkResults.js";
2
3
 
3
4
  export function reportJson(result) {
4
5
  return {
@@ -8,9 +9,11 @@ export function reportJson(result) {
8
9
  summary: {
9
10
  total: result.findings.length,
10
11
  bySeverity: countBySeverity(result.findings),
12
+ byStatus: countByStatus(result.checks || []),
11
13
  failOn: result.config.failOn,
12
14
  exitCode: getExitCode(result.findings, result.config.failOn)
13
15
  },
16
+ checks: result.checks || [],
14
17
  findings: result.findings,
15
18
  warnings: result.warnings
16
19
  };
@@ -0,0 +1,203 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { countByStatus } from "../core/checkResults.js";
4
+
5
+ const ANSI_PATTERN = /\u001B\[[0-?]*[ -/]*[@-~]/g;
6
+
7
+ export async function writeMarkdownReport(result, options = {}) {
8
+ const filePath = options.filePath || path.join(options.directoryPath || process.cwd(), "report.md");
9
+ let overwritten = false;
10
+
11
+ try {
12
+ await fs.access(filePath);
13
+ overwritten = true;
14
+ } catch {
15
+ overwritten = false;
16
+ }
17
+
18
+ await fs.writeFile(filePath, reportMarkdown(result), "utf8");
19
+ return { filePath, overwritten };
20
+ }
21
+
22
+ export function reportMarkdown(result) {
23
+ const checks = result.checks || checksFromFindings(result.findings || []);
24
+ const counts = countByStatus(checks);
25
+ const projectName = result.meta?.packageName || path.basename(result.meta?.rootPath || "") || "unknown";
26
+ const generatedAt = formatTimestamp(result.meta?.completedAt || new Date());
27
+
28
+ return stripAnsi(`${[
29
+ "# ItWorksBut Scan Report",
30
+ "",
31
+ `Generated: ${generatedAt}`,
32
+ "",
33
+ `Project: ${projectName}`,
34
+ `Path: ${result.meta?.rootPath || "unknown"}`,
35
+ "",
36
+ "## Summary",
37
+ "",
38
+ "| Status | Count |",
39
+ "|---|---:|",
40
+ `| Pass | ${counts.pass} |`,
41
+ `| Warn | ${counts.warn} |`,
42
+ `| Fail | ${counts.fail} |`,
43
+ `| Skip | ${counts.skip} |`,
44
+ "",
45
+ "## Checks",
46
+ "",
47
+ renderChecks(checks),
48
+ renderScannerWarnings(result.warnings || []),
49
+ renderRecommendations(checks, result.findings || []),
50
+ ].filter((line) => line !== null && line !== undefined).join("\n")}\n`);
51
+ }
52
+
53
+ function renderChecks(checks) {
54
+ if (!checks.length) return "No checks were recorded.\n";
55
+
56
+ return checks.map(renderCheck).join("\n");
57
+ }
58
+
59
+ function renderCheck(check) {
60
+ return [
61
+ `### ${check.title || check.id}`,
62
+ "",
63
+ `Status: ${check.status || "unknown"}`,
64
+ "",
65
+ "Summary:",
66
+ check.summary || "No summary available.",
67
+ "",
68
+ "Details:",
69
+ renderDetails(check),
70
+ ""
71
+ ].join("\n");
72
+ }
73
+
74
+ function renderDetails(check) {
75
+ const details = Array.isArray(check.details) ? check.details : [];
76
+ if (!details.length) return "None.";
77
+
78
+ if (isOutdatedPackageCheck(check)) {
79
+ return renderOutdatedPackageTable(details);
80
+ }
81
+
82
+ return details.map(renderDetailBullet).join("\n");
83
+ }
84
+
85
+ function renderOutdatedPackageTable(details) {
86
+ const packageDetails = details.filter((detail) => detail?.name);
87
+ if (!packageDetails.length) return details.map(renderDetailBullet).join("\n");
88
+
89
+ return [
90
+ "| Package | Current | Wanted | Latest | Type |",
91
+ "|---|---:|---:|---:|---|",
92
+ ...packageDetails.map((detail) => (
93
+ `| ${escapeTable(detail.name)} | ${escapeTable(detail.current)} | ${escapeTable(detail.wanted)} | ${escapeTable(detail.latest)} | ${escapeTable(detail.type)} |`
94
+ ))
95
+ ].join("\n");
96
+ }
97
+
98
+ function renderDetailBullet(detail) {
99
+ if (typeof detail === "string") return `- ${detail}`;
100
+ if (!detail || typeof detail !== "object") return `- ${String(detail)}`;
101
+
102
+ if (detail.message) return `- ${detail.message}`;
103
+ return `- ${Object.entries(detail).map(([key, value]) => `${key}: ${String(value)}`).join(", ")}`;
104
+ }
105
+
106
+ function renderScannerWarnings(warnings) {
107
+ if (!warnings.length) return "";
108
+
109
+ return [
110
+ "## Scanner Warnings",
111
+ "",
112
+ ...warnings.map((warning) => `- ${warning.checkId}: ${warning.message}`),
113
+ ""
114
+ ].join("\n");
115
+ }
116
+
117
+ function renderRecommendations(checks, findings) {
118
+ const needsReview = checks.some((check) => check.status === "warn" || check.status === "fail");
119
+ if (!needsReview && findings.length === 0) return "";
120
+
121
+ const recommendations = [];
122
+ if (checks.some((check) => isOutdatedPackageCheck(check) && (check.status === "warn" || check.status === "fail"))) {
123
+ recommendations.push("Update outdated packages carefully.");
124
+ recommendations.push("Review major-version upgrades manually.");
125
+ recommendations.push("Run tests after dependency updates.");
126
+ }
127
+
128
+ for (const finding of findings) {
129
+ if (finding.recommendation) recommendations.push(finding.recommendation);
130
+ }
131
+
132
+ if (checks.some((check) => check.status === "fail")) {
133
+ recommendations.push("Review failed checks and scanner warnings before shipping.");
134
+ }
135
+ if (recommendations.length === 0) {
136
+ recommendations.push("Review warning and failure details before shipping.");
137
+ }
138
+
139
+ return [
140
+ "## Recommendations",
141
+ "",
142
+ ...unique(recommendations).map((recommendation) => `- ${recommendation}`),
143
+ ""
144
+ ].join("\n");
145
+ }
146
+
147
+ function checksFromFindings(findings) {
148
+ const byCheck = new Map();
149
+ for (const finding of findings) {
150
+ const existing = byCheck.get(finding.checkId) || {
151
+ id: finding.checkId,
152
+ title: finding.title,
153
+ category: finding.category,
154
+ status: finding.severity === "critical" || finding.severity === "high" ? "fail" : "warn",
155
+ summary: "Finding reported.",
156
+ details: []
157
+ };
158
+ existing.details.push({
159
+ message: finding.message,
160
+ file: finding.file,
161
+ line: finding.line,
162
+ severity: finding.severity
163
+ });
164
+ byCheck.set(finding.checkId, existing);
165
+ }
166
+ return [...byCheck.values()];
167
+ }
168
+
169
+ function isOutdatedPackageCheck(check) {
170
+ return check.id === "dependencies.outdated-packages" || check.title === "Outdated packages";
171
+ }
172
+
173
+ function formatTimestamp(value) {
174
+ const date = value instanceof Date ? value : new Date(value);
175
+ if (Number.isNaN(date.getTime())) return String(value);
176
+
177
+ const pad = (number) => String(number).padStart(2, "0");
178
+ return [
179
+ date.getFullYear(),
180
+ "-",
181
+ pad(date.getMonth() + 1),
182
+ "-",
183
+ pad(date.getDate()),
184
+ " ",
185
+ pad(date.getHours()),
186
+ ":",
187
+ pad(date.getMinutes()),
188
+ ":",
189
+ pad(date.getSeconds())
190
+ ].join("");
191
+ }
192
+
193
+ function escapeTable(value) {
194
+ return stripAnsi(String(value ?? "unknown")).replace(/\|/g, "\\|");
195
+ }
196
+
197
+ function stripAnsi(value) {
198
+ return String(value).replace(ANSI_PATTERN, "");
199
+ }
200
+
201
+ function unique(values) {
202
+ return [...new Set(values.filter(Boolean))];
203
+ }
@@ -0,0 +1,157 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const ANSI_PATTERN = /\u001B\[[0-?]*[ -/]*[@-~]/g;
5
+
6
+ export async function writeStressMarkdownReport(result, options = {}) {
7
+ const filePath = options.filePath || path.join(options.directoryPath || process.cwd(), "stress-report.md");
8
+ let overwritten = false;
9
+
10
+ try {
11
+ await fs.access(filePath);
12
+ overwritten = true;
13
+ } catch {
14
+ overwritten = false;
15
+ }
16
+
17
+ await fs.writeFile(filePath, reportStressMarkdown(result), "utf8");
18
+ return { filePath, overwritten };
19
+ }
20
+
21
+ export function reportStressMarkdown(result) {
22
+ const details = result.details || {};
23
+ const tested = details.testedEndpoints || [];
24
+ const skipped = details.skippedEndpoints || [];
25
+
26
+ return stripAnsi(`${[
27
+ "# ItWorksBut Stress Report",
28
+ "",
29
+ `Generated: ${formatTimestamp(details.completedAt || new Date())}`,
30
+ "",
31
+ `Target: ${details.target || "unknown"}`,
32
+ `Duration: ${details.duration ?? "unknown"}s`,
33
+ `Arrival rate: ${details.arrivalRate ?? "unknown"} req/s`,
34
+ `Max virtual users: ${details.maxVusers ?? "unknown"}`,
35
+ "",
36
+ "## Summary",
37
+ "",
38
+ "| Metric | Value |",
39
+ "|---|---:|",
40
+ `| Endpoints found | ${details.endpointsFound || 0} |`,
41
+ `| Endpoints tested | ${tested.length} |`,
42
+ `| Endpoints skipped | ${skipped.length} |`,
43
+ `| Warnings | ${details.warnings || 0} |`,
44
+ `| Failed | ${details.failed || 0} |`,
45
+ "",
46
+ "## Tested Endpoints",
47
+ "",
48
+ renderTestedEndpoints(tested),
49
+ "",
50
+ "## Skipped Endpoints",
51
+ "",
52
+ renderSkippedEndpoints(skipped),
53
+ renderArtilleryError(details.artilleryError),
54
+ renderRecommendations(result),
55
+ ].filter((line) => line !== null && line !== undefined).join("\n")}\n`);
56
+ }
57
+
58
+ function renderTestedEndpoints(endpoints) {
59
+ if (!endpoints.length) return "None.";
60
+
61
+ return [
62
+ "| Method | Path | Status | p95 | p99 | Errors | Error Rate |",
63
+ "|---|---|---|---:|---:|---:|---:|",
64
+ ...endpoints.map((endpoint) => (
65
+ `| ${escapeTable(endpoint.method)} | ${escapeTable(endpoint.path)} | ${escapeTable(endpoint.status)} | ${formatMs(endpoint.p95)} | ${formatMs(endpoint.p99)} | ${endpoint.errors || 0} | ${formatPercent(endpoint.errorRate)} |`
66
+ ))
67
+ ].join("\n");
68
+ }
69
+
70
+ function renderSkippedEndpoints(endpoints) {
71
+ if (!endpoints.length) return "None.";
72
+
73
+ return [
74
+ "| Method | Path | Reason |",
75
+ "|---|---|---|",
76
+ ...endpoints.map((endpoint) => (
77
+ `| ${escapeTable(endpoint.method)} | ${escapeTable(endpoint.path)} | ${escapeTable(endpoint.reason)} |`
78
+ ))
79
+ ].join("\n");
80
+ }
81
+
82
+ function renderRecommendations(result) {
83
+ const details = result.details || {};
84
+ const recommendations = [];
85
+
86
+ if ((details.warnings || 0) > 0) {
87
+ recommendations.push("Review slow endpoints.");
88
+ recommendations.push("Add caching or pagination where needed.");
89
+ }
90
+ if ((details.failed || 0) > 0 || result.status === "fail") {
91
+ recommendations.push("Investigate failed requests before increasing load.");
92
+ }
93
+ if ((details.skippedEndpoints || []).some((endpoint) => endpoint.reason === "unsafe method")) {
94
+ recommendations.push("Test mutating endpoints manually in a safe staging environment.");
95
+ }
96
+ recommendations.push("Add rate limits before public deployment.");
97
+
98
+ return [
99
+ "",
100
+ "## Recommendations",
101
+ "",
102
+ ...unique(recommendations).map((recommendation) => `- ${recommendation}`),
103
+ ""
104
+ ].join("\n");
105
+ }
106
+
107
+ function renderArtilleryError(error) {
108
+ if (!error) return "";
109
+
110
+ return [
111
+ "",
112
+ "## Artillery Error",
113
+ "",
114
+ error,
115
+ ""
116
+ ].join("\n");
117
+ }
118
+
119
+ function formatTimestamp(value) {
120
+ const date = value instanceof Date ? value : new Date(value);
121
+ if (Number.isNaN(date.getTime())) return String(value);
122
+
123
+ const pad = (number) => String(number).padStart(2, "0");
124
+ return [
125
+ date.getFullYear(),
126
+ "-",
127
+ pad(date.getMonth() + 1),
128
+ "-",
129
+ pad(date.getDate()),
130
+ " ",
131
+ pad(date.getHours()),
132
+ ":",
133
+ pad(date.getMinutes()),
134
+ ":",
135
+ pad(date.getSeconds())
136
+ ].join("");
137
+ }
138
+
139
+ function formatMs(value) {
140
+ return value === null || value === undefined ? "n/a" : `${Math.round(value)} ms`;
141
+ }
142
+
143
+ function formatPercent(value) {
144
+ return `${Number(value || 0).toFixed(Number.isInteger(value) ? 0 : 2).replace(/\.00$/, "")}%`;
145
+ }
146
+
147
+ function escapeTable(value) {
148
+ return stripAnsi(String(value ?? "unknown")).replace(/\|/g, "\\|");
149
+ }
150
+
151
+ function stripAnsi(value) {
152
+ return String(value).replace(ANSI_PATTERN, "");
153
+ }
154
+
155
+ function unique(values) {
156
+ return [...new Set(values.filter(Boolean))];
157
+ }
@@ -0,0 +1,83 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import { fileExists } from "../utils/fs.js";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const TOOL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
11
+
12
+ export async function runArtillery(config, options = {}) {
13
+ if (options.execute) return await options.execute(config, options);
14
+
15
+ const rootPath = options.rootPath || process.cwd();
16
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "itworksbut-stress-"));
17
+ const configPath = path.join(tempDir, "artillery-config.json");
18
+ const outputPath = path.join(tempDir, "artillery-report.json");
19
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
20
+
21
+ const { command, args } = await resolveArtilleryCommand(rootPath, configPath, outputPath);
22
+ const timeout = Math.max(90_000, ((options.duration || 30) + 60) * 1000);
23
+
24
+ try {
25
+ const result = await execFileAsync(command, args, {
26
+ cwd: rootPath,
27
+ timeout,
28
+ maxBuffer: 10 * 1024 * 1024
29
+ });
30
+ return {
31
+ ok: true,
32
+ command,
33
+ args,
34
+ stdout: result.stdout || "",
35
+ stderr: result.stderr || "",
36
+ exitCode: 0,
37
+ report: await readJsonFile(outputPath)
38
+ };
39
+ } catch (error) {
40
+ return {
41
+ ok: false,
42
+ command,
43
+ args,
44
+ stdout: error?.stdout || "",
45
+ stderr: error?.stderr || error?.message || "",
46
+ exitCode: error?.code ?? null,
47
+ signal: error?.signal,
48
+ report: await readJsonFile(outputPath)
49
+ };
50
+ }
51
+ }
52
+
53
+ async function resolveArtilleryCommand(rootPath, configPath, outputPath) {
54
+ const binaryName = process.platform === "win32" ? "artillery.cmd" : "artillery";
55
+ const toolBin = path.join(TOOL_ROOT, "node_modules", ".bin", binaryName);
56
+ if (await fileExists(toolBin)) {
57
+ return {
58
+ command: toolBin,
59
+ args: ["run", configPath, "--output", outputPath]
60
+ };
61
+ }
62
+
63
+ const projectBin = path.join(rootPath, "node_modules", ".bin", binaryName);
64
+ if (await fileExists(projectBin)) {
65
+ return {
66
+ command: projectBin,
67
+ args: ["run", configPath, "--output", outputPath]
68
+ };
69
+ }
70
+
71
+ return {
72
+ command: process.platform === "win32" ? "npm.cmd" : "npm",
73
+ args: ["exec", "--yes", "artillery", "--", "run", configPath, "--output", outputPath]
74
+ };
75
+ }
76
+
77
+ async function readJsonFile(filePath) {
78
+ try {
79
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
80
+ } catch {
81
+ return null;
82
+ }
83
+ }