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.
package/README.md CHANGED
@@ -6,7 +6,19 @@ It focuses on common "it works, but..." risks often found in AI-generated or rus
6
6
 
7
7
  For every finding, ItWorksBut gives you a copy-ready fix prompt you can paste into your coding agent. It does not just say what is wrong; it tells your AI exactly what to inspect, what to change, and what not to leak.
8
8
 
9
- It only reads files and reports findings. It does not call external APIs, does not send telemetry, and does not modify the scanned project unless you explicitly ask it to write `todo.md` with `--todo`.
9
+ It mostly reads files and reports findings. It does not send telemetry. The outdated-package check may invoke your local package manager, and the CLI only writes files when you explicitly ask for `todo.md` with `--todo` or `report.md` with `--report`.
10
+
11
+ ## Table of Contents
12
+
13
+ - [Installation](#installation)
14
+ - [Quick Start](#quick-start)
15
+ - [Options](#options)
16
+ - [Terminal Experience](#terminal-experience)
17
+ - [GitHub Actions](#github-actions)
18
+ - [Configuration](#configuration)
19
+ - [Example Output](#example-output)
20
+ - [What It Detects](#what-it-detects)
21
+ - [What It Does Not Guarantee](#what-it-does-not-guarantee)
10
22
 
11
23
  ## Installation
12
24
 
@@ -37,10 +49,13 @@ Common commands:
37
49
 
38
50
  ```sh
39
51
  itworksbut scan --path .
52
+ itworksbut deps
53
+ itworksbut stress
40
54
  itworksbut scan --fail-on high
41
55
  itworksbut scan --json
42
56
  itworksbut scan --sarif > itworksbut.sarif
43
57
  itworksbut scan --todo
58
+ itworksbut scan --report
44
59
  itworksbut scan --config itworksbut.config.json
45
60
  itworksbut scan --verbose
46
61
  itworksbut --version
@@ -50,14 +65,24 @@ itworksbut --version
50
65
 
51
66
  ```text
52
67
  itworksbut scan [options]
68
+ itworksbut deps [options]
69
+ itworksbut stress [options]
53
70
  ```
54
71
 
72
+ - `deps`: Run only dependency checks, including lockfile hygiene, install-script risk, audit script availability, and outdated packages.
73
+ - `stress`: Discover API endpoints and run a controlled Artillery load test against local or explicitly authorized targets.
55
74
  - `--path <path>`: Scan a specific project directory. Defaults to the current directory.
56
75
  - `--config <path>`: Use a custom config file. Defaults to `itworksbut.config.json` when present.
57
76
  - `--fail-on <severity>`: Exit with code `1` when a finding at or above the severity exists. Levels: `critical`, `high`, `medium`, `low`, `info`. Default: `low`.
77
+ - `--target <url>`: Stress-test target. Defaults to `http://localhost:3000`. External targets require `--i-own-this`.
78
+ - `--duration <seconds>`: Stress-test duration. Default `30`, maximum `300`.
79
+ - `--arrival-rate <number>`: Arrival rate in requests per second. Default `5`, maximum `50`.
80
+ - `--max-vusers <number>`: Virtual user cap. Default `50`, maximum `100`.
81
+ - `--i-own-this`: Required for non-local stress-test targets.
58
82
  - `--json`: Print machine-readable JSON only. No banner, colors, spinner, table, or extra text.
59
83
  - `--sarif`: Print SARIF JSON for GitHub Code Scanning. No banner, colors, spinner, table, or extra text.
60
84
  - `--todo`: Write an AI-ready `todo.md` into the scanned project with prioritized findings, fix prompts, and acceptance criteria.
85
+ - `--report`: Write a Markdown `report.md` into the current working directory.
61
86
  - `--verbose`: Include scanner warnings and extra metadata in console output.
62
87
  - `--quiet`: Print only the summary.
63
88
  - `--no-color`: Disable colored output.
@@ -97,6 +122,24 @@ itworksbut scan --todo
97
122
 
98
123
  This writes `todo.md` to the scanned project. The file is ordered by severity and includes agent rules, exact fix prompts, locations, recommendations, and final verification checkboxes.
99
124
 
125
+ To create a Markdown scan report:
126
+
127
+ ```sh
128
+ itworksbut scan --report
129
+ ```
130
+
131
+ This writes `report.md` to the current working directory with check statuses, summaries, details, and recommendations.
132
+
133
+ To run a controlled API stress test:
134
+
135
+ ```sh
136
+ itworksbut stress
137
+ itworksbut stress --target https://my-own-api.example --i-own-this
138
+ itworksbut stress --report
139
+ ```
140
+
141
+ `stress` only tests local targets by default. For external hosts, pass `--i-own-this` to confirm that you own the target or have explicit authorization. Mutating endpoints such as `POST`, `PUT`, `PATCH`, and `DELETE` are discovered but skipped automatically.
142
+
100
143
  ## GitHub Actions
101
144
 
102
145
  ```yaml
@@ -166,38 +209,10 @@ release/**
166
209
  ## Example Output
167
210
 
168
211
  ![screenshot of an example output](/assets/medium_problems.webp)
169
- ✖ CRITICAL It works, but your .env is tracked.
170
- ✔ Check: env.env-file-tracked
171
- 📁 File: .env
172
- 🤔 Why: .env appears to be tracked by git. Secrets may be exposed.
173
- 🤖 prompt: You are a senior security engineer working in this repository. Fix the ItWorksBut finding env.env-file-tracked at .env. Treat this as a concrete finding. Problem: .env appears to be tracked by git. Secrets may be exposed. Required change: Remove tracked env files from git, add safe examples such as .env.example, and make sure any exposed credentials are treated as compromised. Do not print, log, or preserve raw secret values; use placeholders only. Keep existing behavior intact where possible, add or update focused tests when useful, and do not silence the check unless the underlying risk is actually fixed.
174
-
175
- ▲ HIGH It works, but your SQL query is one template string away from pain.
176
- ✔ Check: database.raw-sql-interpolation
177
- 📁 File: src/db.js:12
178
- 🤔 Why: Possible SQL injection risk: raw SQL appears to be built with template string interpolation.
179
- 🤖 prompt: You are a senior security engineer working in this repository. Fix the ItWorksBut finding database.raw-sql-interpolation at src/db.js:12. This finding is heuristic, so inspect the code first and only change behavior when the risk is real. Problem: Possible SQL injection risk: raw SQL appears to be built with template string interpolation. Required change: Replace SQL string interpolation or concatenation with parameterized queries, prepared statements, or a safe ORM query builder. Keep existing behavior intact where possible, add or update focused tests when useful, and do not silence the check unless the underlying risk is actually fixed.
180
-
181
- SUMMARY
182
-
183
- - ship status: DO NOT SHIP
184
- - Fix the red stuff before production.
185
- - total findings: 2
186
- - critical: 1
187
- - high: 1
188
- - medium: 0
189
- - low: 0
190
- - info: 0
191
- - fail-on: high
192
- - exit decision: 1
193
-
194
- ```
195
-
196
- Secret-like findings never print the full secret value. Findings report the file, line, and secret type where possible.
197
212
 
198
213
  ## What It Detects
199
214
 
200
- The baseline includes 50 modular checks:
215
+ The baseline includes 51 modular checks:
201
216
 
202
217
  - `git.gitignore-missing`
203
218
  - `git.gitignore-incomplete`
@@ -211,6 +226,7 @@ The baseline includes 50 modular checks:
211
226
  - `dependencies.multiple-lockfiles`
212
227
  - `dependencies.install-scripts-risk`
213
228
  - `dependencies.audit-script-missing`
229
+ - `dependencies.outdated-packages`
214
230
  - `package.scripts-missing`
215
231
  - `ci.no-ci-config`
216
232
  - `ci.npm-install-instead-of-npm-ci`
@@ -257,4 +273,3 @@ Each check is a plain ESM module with an `id`, metadata, and async `run(context)
257
273
  ItWorksBut is a static heuristic scanner, not a pentest, SAST replacement, dependency vulnerability database, or runtime security monitor. Findings intentionally use wording such as "possible", "potential", and "appears to" when a check is heuristic.
258
274
 
259
275
  Use it as a CI guardrail for common project hygiene and security mistakes. For production systems, combine it with code review, tests, dependency scanning, secrets scanning, threat modeling, and focused security assessment.
260
- ```
package/bin/itworksbut.js CHANGED
@@ -10,6 +10,8 @@ import { reportConsole } from '../src/reporters/consoleReporter.js';
10
10
  import { reportJson } from '../src/reporters/jsonReporter.js';
11
11
  import { reportSarif } from '../src/reporters/sarifReporter.js';
12
12
  import { writeTodoReport } from '../src/reporters/todoReporter.js';
13
+ import { writeMarkdownReport } from '../src/reporters/markdownReport.js';
14
+ import { runStressCommand } from '../src/commands/stress.js';
13
15
 
14
16
  async function main() {
15
17
  const args = parseArgs(process.argv.slice(2));
@@ -24,7 +26,11 @@ async function main() {
24
26
  return 0;
25
27
  }
26
28
 
27
- if (args.command !== 'scan') {
29
+ if (args.command === 'stress') {
30
+ return await runStressCommand(args);
31
+ }
32
+
33
+ if (!['scan', 'deps'].includes(args.command)) {
28
34
  printUsage();
29
35
  return 2;
30
36
  }
@@ -41,6 +47,7 @@ async function main() {
41
47
  configPath: args.config,
42
48
  failOn: args.failOn,
43
49
  verbose: args.verbose,
50
+ categories: args.command === 'deps' ? ['dependencies'] : undefined,
44
51
  });
45
52
  if (spinner) spinner.succeed('Scan complete. Now the receipts.');
46
53
  } catch (error) {
@@ -59,6 +66,14 @@ async function main() {
59
66
  reportConsole(result, args);
60
67
  }
61
68
 
69
+ if (args.report) {
70
+ const report = await writeMarkdownReport(result);
71
+ if (!args.json && !args.sarif) {
72
+ const verb = report.overwritten ? 'Overwrote' : 'Wrote';
73
+ process.stdout.write(`${verb} scan report: ${report.filePath}\n`);
74
+ }
75
+ }
76
+
62
77
  return getExitCode(result.findings, result.config.failOn);
63
78
  }
64
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "itworksbut",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Static CI checks for common security, repo, dependency, build, and deployment risks in JavaScript vibe coding projects.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,6 +36,7 @@
36
36
  ],
37
37
  "license": "MIT",
38
38
  "dependencies": {
39
+ "artillery": "^2.0.30",
39
40
  "boxen": "^8.0.1",
40
41
  "chalk": "^5.6.2",
41
42
  "cli-table3": "^0.6.5",
@@ -0,0 +1,297 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { detectOutdatedPackageManager, getOutdatedCommand } from "../../utils/packageManager.js";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+ const CHECK_ID = "dependencies.outdated-packages";
7
+ const CHECK_TITLE = "Outdated packages";
8
+ const COMMAND_TIMEOUT_MS = 30_000;
9
+ const COMMAND_MAX_BUFFER = 10 * 1024 * 1024;
10
+
11
+ export default {
12
+ id: CHECK_ID,
13
+ title: CHECK_TITLE,
14
+ category: "dependencies",
15
+ severity: "info",
16
+ tags: ["dependencies", "maintenance"],
17
+ run: async (context) => {
18
+ const result = await runOutdatedPackagesCheck(context);
19
+ return { findings: [], result };
20
+ }
21
+ };
22
+
23
+ export async function runOutdatedPackagesCheck(context, options = {}) {
24
+ const detected = detectOutdatedPackageManager({
25
+ packageJson: context.packageJson,
26
+ files: context.allFiles
27
+ });
28
+
29
+ if (detected.status === "skip") {
30
+ return checkResult({
31
+ status: "skip",
32
+ summary: detected.summary,
33
+ metadata: { reason: "missing-package-json" }
34
+ });
35
+ }
36
+
37
+ const commandResult = await runOutdatedCommand(detected.manager, context.rootPath, options);
38
+ if (isMissingCommand(commandResult)) {
39
+ return checkResult({
40
+ status: "fail",
41
+ summary: `${detected.manager} is not installed or could not be found.`,
42
+ details: [
43
+ {
44
+ message: `${detected.manager} is not installed or could not be found.`,
45
+ command: renderCommand(commandResult),
46
+ exitCode: commandResult.exitCode
47
+ }
48
+ ],
49
+ metadata: { packageManager: detected.manager }
50
+ });
51
+ }
52
+
53
+ const parsed = parseOutdatedOutput(detected.manager, commandResult.stdout, context.packageJson);
54
+ if (!parsed.ok) {
55
+ if (!hasCommandFailure(commandResult) && isEmptyOutput(commandResult.stdout)) {
56
+ return checkResult({
57
+ status: "pass",
58
+ summary: "all dependencies are up to date",
59
+ details: [],
60
+ metadata: { packageManager: detected.manager }
61
+ });
62
+ }
63
+
64
+ return checkResult({
65
+ status: "fail",
66
+ summary: `${detected.manager} outdated returned output that could not be parsed.`,
67
+ details: [
68
+ {
69
+ message: parsed.error,
70
+ command: renderCommand(commandResult),
71
+ exitCode: commandResult.exitCode,
72
+ stderr: trimText(commandResult.stderr)
73
+ }
74
+ ],
75
+ metadata: { packageManager: detected.manager }
76
+ });
77
+ }
78
+
79
+ if ((hasCommandFailure(commandResult) || hasEmptyExitOneFailure(commandResult)) && parsed.packages.length === 0) {
80
+ return checkResult({
81
+ status: "fail",
82
+ summary: `${detected.manager} outdated failed.`,
83
+ details: [
84
+ {
85
+ message: trimText(commandResult.stderr) || "Command exited with code " + commandResult.exitCode + ".",
86
+ command: renderCommand(commandResult),
87
+ exitCode: commandResult.exitCode
88
+ }
89
+ ],
90
+ metadata: { packageManager: detected.manager }
91
+ });
92
+ }
93
+
94
+ if (parsed.packages.length === 0) {
95
+ return checkResult({
96
+ status: "pass",
97
+ summary: "all dependencies are up to date",
98
+ details: [],
99
+ metadata: { packageManager: detected.manager }
100
+ });
101
+ }
102
+
103
+ return checkResult({
104
+ status: "warn",
105
+ summary: `${parsed.packages.length} ${parsed.packages.length === 1 ? "package" : "packages"} outdated`,
106
+ details: parsed.packages,
107
+ metadata: { packageManager: detected.manager }
108
+ });
109
+ }
110
+
111
+ export async function runOutdatedCommand(manager, rootPath, options = {}) {
112
+ const runCommand = options.runCommand || execCommand;
113
+ const { command, args } = getOutdatedCommand(manager);
114
+ return await runCommand(command, args, { cwd: rootPath });
115
+ }
116
+
117
+ export function parseOutdatedOutput(manager, stdout, packageJson = {}) {
118
+ const output = String(stdout || "").trim();
119
+ if (!output) return { ok: true, packages: [] };
120
+
121
+ try {
122
+ if (manager === "yarn") {
123
+ return { ok: true, packages: parseYarnOutput(output, packageJson) };
124
+ }
125
+
126
+ const parsed = JSON.parse(output);
127
+ return { ok: true, packages: normalizeOutdatedData(parsed, packageJson) };
128
+ } catch (error) {
129
+ return {
130
+ ok: false,
131
+ packages: [],
132
+ error: error instanceof Error ? error.message : String(error)
133
+ };
134
+ }
135
+ }
136
+
137
+ export function normalizeOutdatedData(data, packageJson = {}) {
138
+ if (!data || typeof data !== "object") return [];
139
+
140
+ if (Array.isArray(data)) {
141
+ return data
142
+ .map((entry) => normalizePackageEntry(entry.name || entry.package, entry, packageJson))
143
+ .filter(Boolean);
144
+ }
145
+
146
+ return Object.entries(data)
147
+ .map(([name, entry]) => normalizePackageEntry(name, entry, packageJson))
148
+ .filter(Boolean);
149
+ }
150
+
151
+ async function execCommand(command, args, options) {
152
+ try {
153
+ const result = await execFileAsync(command, args, {
154
+ cwd: options.cwd,
155
+ timeout: COMMAND_TIMEOUT_MS,
156
+ maxBuffer: COMMAND_MAX_BUFFER
157
+ });
158
+ return {
159
+ command,
160
+ args,
161
+ stdout: result.stdout || "",
162
+ stderr: result.stderr || "",
163
+ exitCode: 0
164
+ };
165
+ } catch (error) {
166
+ return {
167
+ command,
168
+ args,
169
+ stdout: error?.stdout || "",
170
+ stderr: error?.stderr || error?.message || "",
171
+ exitCode: error?.code ?? null,
172
+ signal: error?.signal,
173
+ error
174
+ };
175
+ }
176
+ }
177
+
178
+ function parseYarnOutput(output, packageJson) {
179
+ const directJson = tryJson(output);
180
+ if (directJson.ok) {
181
+ if (directJson.value?.type === "table") return parseYarnTable(directJson.value.data, packageJson);
182
+ return normalizeOutdatedData(directJson.value, packageJson);
183
+ }
184
+
185
+ const records = output
186
+ .split(/\r?\n/)
187
+ .map((line) => line.trim())
188
+ .filter(Boolean)
189
+ .map((line) => tryJson(line))
190
+ .filter((record) => record.ok)
191
+ .map((record) => record.value);
192
+
193
+ const tableRecord = records.find((record) => record?.type === "table" && record.data);
194
+ if (tableRecord) return parseYarnTable(tableRecord.data, packageJson);
195
+
196
+ const packageRecords = records
197
+ .filter((record) => record?.data && typeof record.data === "object")
198
+ .map((record) => record.data);
199
+ if (packageRecords.length > 0) return normalizeOutdatedData(packageRecords, packageJson);
200
+
201
+ throw new Error("No parseable Yarn outdated JSON records were found.");
202
+ }
203
+
204
+ function parseYarnTable(data, packageJson) {
205
+ const head = Array.isArray(data?.head) ? data.head.map((value) => String(value).toLowerCase()) : [];
206
+ const body = Array.isArray(data?.body) ? data.body : [];
207
+
208
+ return body.map((row) => {
209
+ const entry = {
210
+ name: row[indexOf(head, "package", 0)],
211
+ current: row[indexOf(head, "current", 1)],
212
+ wanted: row[indexOf(head, "wanted", 2)],
213
+ latest: row[indexOf(head, "latest", 3)],
214
+ type: row[indexOf(head, "package type", 4)]
215
+ };
216
+ return normalizePackageEntry(entry.name, entry, packageJson);
217
+ }).filter(Boolean);
218
+ }
219
+
220
+ function normalizePackageEntry(name, entry, packageJson) {
221
+ if (!name || !entry || typeof entry !== "object") return null;
222
+
223
+ const current = stringValue(entry.current ?? entry.installed ?? entry.version);
224
+ const wanted = stringValue(entry.wanted ?? entry.latest);
225
+ const latest = stringValue(entry.latest ?? entry.wanted);
226
+
227
+ if (!current && !wanted && !latest) return null;
228
+
229
+ return {
230
+ name: String(name),
231
+ current: current || "unknown",
232
+ wanted: wanted || "unknown",
233
+ latest: latest || "unknown",
234
+ type: stringValue(entry.type ?? entry.dependencyType ?? entry.packageType) || inferDependencyType(name, packageJson)
235
+ };
236
+ }
237
+
238
+ function inferDependencyType(name, packageJson = {}) {
239
+ if (Object.hasOwn(packageJson.dependencies || {}, name)) return "dependencies";
240
+ if (Object.hasOwn(packageJson.devDependencies || {}, name)) return "devDependencies";
241
+ if (Object.hasOwn(packageJson.peerDependencies || {}, name)) return "peerDependencies";
242
+ if (Object.hasOwn(packageJson.optionalDependencies || {}, name)) return "optionalDependencies";
243
+ return "dependencies";
244
+ }
245
+
246
+ function checkResult(result) {
247
+ return {
248
+ id: CHECK_ID,
249
+ title: CHECK_TITLE,
250
+ category: "dependencies",
251
+ details: [],
252
+ ...result
253
+ };
254
+ }
255
+
256
+ function hasCommandFailure(result) {
257
+ return result.exitCode !== 0 && result.exitCode !== 1;
258
+ }
259
+
260
+ function hasEmptyExitOneFailure(result) {
261
+ return result.exitCode === 1 && isEmptyOutput(result.stdout) && !isEmptyOutput(result.stderr);
262
+ }
263
+
264
+ function isMissingCommand(result) {
265
+ return result.exitCode === "ENOENT" || result.error?.code === "ENOENT";
266
+ }
267
+
268
+ function isEmptyOutput(value) {
269
+ return String(value || "").trim() === "";
270
+ }
271
+
272
+ function renderCommand(result) {
273
+ return [result.command, ...(result.args || [])].filter(Boolean).join(" ");
274
+ }
275
+
276
+ function trimText(value) {
277
+ const normalized = String(value || "").trim();
278
+ return normalized.length > 1000 ? `${normalized.slice(0, 1000)}...` : normalized;
279
+ }
280
+
281
+ function tryJson(value) {
282
+ try {
283
+ return { ok: true, value: JSON.parse(value) };
284
+ } catch {
285
+ return { ok: false, value: null };
286
+ }
287
+ }
288
+
289
+ function indexOf(head, name, fallback) {
290
+ const index = head.indexOf(name);
291
+ return index === -1 ? fallback : index;
292
+ }
293
+
294
+ function stringValue(value) {
295
+ if (value === undefined || value === null || value === "") return "";
296
+ return String(value);
297
+ }
@@ -10,6 +10,7 @@ import lockfileMissing from "./dependencies/lockfile-missing.js";
10
10
  import multipleLockfiles from "./dependencies/multiple-lockfiles.js";
11
11
  import installScriptsRisk from "./dependencies/install-scripts-risk.js";
12
12
  import auditScriptMissing from "./dependencies/audit-script-missing.js";
13
+ import outdatedPackages from "./dependencies/outdated-packages.js";
13
14
  import packageScriptsMissing from "./package/scripts-missing.js";
14
15
  import noCiConfig from "./ci/no-ci-config.js";
15
16
  import npmInstallInsteadOfNpmCi from "./ci/npm-install-instead-of-npm-ci.js";
@@ -62,6 +63,7 @@ export default [
62
63
  multipleLockfiles,
63
64
  installScriptsRisk,
64
65
  auditScriptMissing,
66
+ outdatedPackages,
65
67
  packageScriptsMissing,
66
68
  noCiConfig,
67
69
  npmInstallInsteadOfNpmCi,
package/src/cli/output.js CHANGED
@@ -5,15 +5,23 @@ export function printUsage() {
5
5
 
6
6
  Usage:
7
7
  itworksbut scan [options]
8
+ itworksbut deps [options]
9
+ itworksbut stress [options]
8
10
 
9
11
  Options:
10
12
  --path <path> Project path to scan. Defaults to current directory.
11
13
  --config <path> Optional itworksbut.config.json path.
12
14
  --fail-on <level> Exit 1 when findings meet or exceed this severity.
13
15
  Levels: critical, high, medium, low, info. Default: low.
16
+ --target <url> Stress-test target. Defaults to http://localhost:3000.
17
+ --duration <sec> Stress-test duration. Default: 30, max: 300.
18
+ --arrival-rate <n> Stress-test arrival rate. Default: 5, max: 50.
19
+ --max-vusers <n> Stress-test virtual user cap. Default: 50, max: 100.
20
+ --i-own-this Required to stress-test non-local targets.
14
21
  --json Print machine-readable JSON.
15
22
  --sarif Print SARIF for GitHub Code Scanning.
16
23
  --todo Write an AI-ready todo.md to the scanned project.
24
+ --report Write a Markdown scan report.md to the current directory.
17
25
  --no-color Disable color styling.
18
26
  --no-banner Disable the intro banner.
19
27
  --quiet Print only the summary.
@@ -1,5 +1,5 @@
1
- const FLAG_WITH_VALUE = new Set(["--fail-on", "--config", "--path"]);
2
- const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--todo", "--verbose", "--help", "-h", "--version", "-v", "--no-color", "--no-banner", "--quiet"]);
1
+ const FLAG_WITH_VALUE = new Set(["--fail-on", "--config", "--path", "--target", "--duration", "--arrival-rate", "--max-vusers"]);
2
+ const BOOLEAN_FLAGS = new Set(["--json", "--sarif", "--todo", "--report", "--i-own-this", "--verbose", "--help", "-h", "--version", "-v", "--no-color", "--no-banner", "--quiet"]);
3
3
 
4
4
  export function parseArgs(argv) {
5
5
  const args = {
@@ -10,6 +10,12 @@ export function parseArgs(argv) {
10
10
  json: false,
11
11
  sarif: false,
12
12
  todo: false,
13
+ report: false,
14
+ target: undefined,
15
+ duration: undefined,
16
+ arrivalRate: undefined,
17
+ maxVusers: undefined,
18
+ iOwnThis: false,
13
19
  verbose: false,
14
20
  noColor: false,
15
21
  noBanner: false,
@@ -48,6 +54,8 @@ export function parseArgs(argv) {
48
54
  if (token === "--json") args.json = true;
49
55
  if (token === "--sarif") args.sarif = true;
50
56
  if (token === "--todo") args.todo = true;
57
+ if (token === "--report") args.report = true;
58
+ if (token === "--i-own-this") args.iOwnThis = true;
51
59
  if (token === "--verbose") args.verbose = true;
52
60
  if (token === "--no-color") args.noColor = true;
53
61
  if (token === "--no-banner") args.noBanner = true;
@@ -69,5 +77,9 @@ function assignValue(args, flag, value) {
69
77
  if (flag === "--fail-on") args.failOn = value;
70
78
  else if (flag === "--config") args.config = value;
71
79
  else if (flag === "--path") args.path = value;
80
+ else if (flag === "--target") args.target = value;
81
+ else if (flag === "--duration") args.duration = value;
82
+ else if (flag === "--arrival-rate") args.arrivalRate = value;
83
+ else if (flag === "--max-vusers") args.maxVusers = value;
72
84
  else throw new Error(`Unknown argument: ${flag}`);
73
85
  }
@@ -0,0 +1,114 @@
1
+ import path from "node:path";
2
+ import { validateStressOptions } from "../utils/targetSafety.js";
3
+ import { discoverEndpoints } from "../stress/discoverEndpoints.js";
4
+ import { createArtilleryConfig } from "../stress/stressConfig.js";
5
+ import { runArtillery } from "../stress/artilleryRunner.js";
6
+ import { parseArtilleryResult } from "../stress/stressResultParser.js";
7
+ import { reportStressConsole } from "../stress/stressRenderer.js";
8
+ import { writeStressMarkdownReport } from "../reporters/stressMarkdownReport.js";
9
+
10
+ export async function runStressCommand(args, options = {}) {
11
+ if (args.todo || args.sarif) {
12
+ throw new Error("The stress command supports console output, --json, and --report.");
13
+ }
14
+
15
+ const result = await runStress({
16
+ rootPath: path.resolve(args.path || "."),
17
+ ...validateStressOptions(args)
18
+ }, options);
19
+
20
+ if (args.json) {
21
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
22
+ } else {
23
+ reportStressConsole(result, args);
24
+ }
25
+
26
+ if (args.report) {
27
+ const report = await writeStressMarkdownReport(result);
28
+ if (!args.json && !args.quiet) {
29
+ const verb = report.overwritten ? "Overwrote" : "Wrote";
30
+ process.stdout.write(`${verb} stress report: ${report.filePath}\n`);
31
+ }
32
+ }
33
+
34
+ return result.status === "fail" ? 1 : 0;
35
+ }
36
+
37
+ export async function runStress(options, runnerOptions = {}) {
38
+ const startedAt = new Date();
39
+ const discovery = await discoverEndpoints(options.rootPath);
40
+ const baseDetails = {
41
+ target: options.target,
42
+ duration: options.duration,
43
+ arrivalRate: options.arrivalRate,
44
+ maxVusers: options.maxVusers,
45
+ endpointsFound: discovery.endpoints.length,
46
+ safeEndpoints: discovery.safeEndpoints.length,
47
+ skippedEndpoints: discovery.skippedEndpoints,
48
+ startedAt: startedAt.toISOString()
49
+ };
50
+
51
+ if (discovery.endpoints.length === 0) {
52
+ return stressResult({
53
+ status: "skip",
54
+ summary: "No API endpoints found.",
55
+ details: {
56
+ ...baseDetails,
57
+ testedEndpoints: [],
58
+ warnings: 0,
59
+ failed: 0,
60
+ completedAt: new Date().toISOString()
61
+ }
62
+ });
63
+ }
64
+
65
+ if (discovery.safeEndpoints.length === 0) {
66
+ return stressResult({
67
+ status: "fail",
68
+ summary: "No safe GET/HEAD endpoints can be tested automatically.",
69
+ details: {
70
+ ...baseDetails,
71
+ testedEndpoints: [],
72
+ warnings: 0,
73
+ failed: 1,
74
+ completedAt: new Date().toISOString()
75
+ }
76
+ });
77
+ }
78
+
79
+ const config = createArtilleryConfig({
80
+ target: options.target,
81
+ duration: options.duration,
82
+ arrivalRate: options.arrivalRate,
83
+ maxVusers: options.maxVusers,
84
+ endpoints: discovery.safeEndpoints
85
+ });
86
+
87
+ const artillery = await runArtillery(config, {
88
+ rootPath: options.rootPath,
89
+ duration: options.duration,
90
+ ...runnerOptions
91
+ });
92
+ const parsed = parseArtilleryResult(artillery, discovery.safeEndpoints);
93
+
94
+ return stressResult({
95
+ status: parsed.status,
96
+ summary: parsed.summary,
97
+ details: {
98
+ ...baseDetails,
99
+ testedEndpoints: parsed.testedEndpoints,
100
+ warnings: parsed.warnings,
101
+ failed: parsed.failed,
102
+ artilleryError: parsed.error,
103
+ completedAt: new Date().toISOString()
104
+ }
105
+ });
106
+ }
107
+
108
+ function stressResult(result) {
109
+ return {
110
+ id: "stress-test",
111
+ title: "API stress test",
112
+ ...result
113
+ };
114
+ }