aztrx-cli 0.4.4 → 0.5.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.
Files changed (98) hide show
  1. package/README.md +325 -28
  2. package/dist/cli/help.d.ts +22 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +278 -81
  5. package/dist/core/auth.d.ts +33 -0
  6. package/dist/core/badge.d.ts +9 -0
  7. package/dist/core/browser.d.ts +3 -0
  8. package/dist/core/classifier.d.ts +41 -0
  9. package/dist/core/cloud/index.d.ts +62 -0
  10. package/dist/core/cloud/index.js +24 -4
  11. package/dist/core/devServer.d.ts +90 -0
  12. package/dist/core/devServer.js +253 -0
  13. package/dist/core/diagnose.d.ts +18 -0
  14. package/dist/core/diff.d.ts +31 -0
  15. package/dist/core/domWalker.d.ts +24 -0
  16. package/dist/core/domWalker.js +1 -1
  17. package/dist/core/eventBus.d.ts +59 -0
  18. package/dist/core/events.d.ts +42 -0
  19. package/dist/core/fixPr.d.ts +16 -0
  20. package/dist/core/fixPr.js +11 -1
  21. package/dist/core/fuzzer.d.ts +24 -0
  22. package/dist/core/fuzzer.js +1 -1
  23. package/dist/core/heal/apply.d.ts +28 -0
  24. package/dist/core/heal/boot.d.ts +74 -0
  25. package/dist/core/heal/boot.js +126 -23
  26. package/dist/core/heal/childEnv.d.ts +15 -0
  27. package/dist/core/heal/gates.d.ts +11 -0
  28. package/dist/core/heal/index.d.ts +19 -0
  29. package/dist/core/heal/index.js +53 -23
  30. package/dist/core/heal/llm.d.ts +43 -0
  31. package/dist/core/heal/redact.d.ts +22 -0
  32. package/dist/core/heal/sandbox.d.ts +54 -0
  33. package/dist/core/heal/sandbox.js +20 -3
  34. package/dist/core/heal/types.d.ts +102 -0
  35. package/dist/core/heal/verify.d.ts +37 -0
  36. package/dist/core/heal/verify.js +28 -14
  37. package/dist/core/httpFuzzer.d.ts +33 -0
  38. package/dist/core/init.d.ts +21 -0
  39. package/dist/core/init.js +5 -6
  40. package/dist/core/interceptor.d.ts +21 -0
  41. package/dist/core/llm.d.ts +36 -0
  42. package/dist/core/llm.js +0 -8
  43. package/dist/core/minimizer.d.ts +13 -0
  44. package/dist/core/modernize.d.ts +28 -0
  45. package/dist/core/modernize.js +2 -2
  46. package/dist/core/networkGuard.d.ts +13 -0
  47. package/dist/core/orchestrator.d.ts +86 -0
  48. package/dist/core/orchestrator.js +24 -9
  49. package/dist/core/patrol/loop.d.ts +46 -0
  50. package/dist/core/patrol/pr.d.ts +42 -0
  51. package/dist/core/patrol/pr.js +9 -6
  52. package/dist/core/patrol/record.d.ts +51 -0
  53. package/dist/core/patrol/state.d.ts +39 -0
  54. package/dist/core/pr.d.ts +3 -0
  55. package/dist/core/pr.js +15 -11
  56. package/dist/core/prompt.d.ts +14 -0
  57. package/dist/core/recorder.d.ts +23 -0
  58. package/dist/core/recorder.js +1 -1
  59. package/dist/core/renderMarkdown.d.ts +7 -0
  60. package/dist/core/replay.d.ts +33 -0
  61. package/dist/core/replay.js +9 -2
  62. package/dist/core/report.d.ts +8 -0
  63. package/dist/core/resolver.d.ts +44 -0
  64. package/dist/core/resolver.js +51 -13
  65. package/dist/core/rng.d.ts +2 -0
  66. package/dist/core/specCompiler.d.ts +17 -0
  67. package/dist/core/studio.d.ts +6 -0
  68. package/dist/core/studio.js +0 -3
  69. package/dist/core/summarize.d.ts +26 -0
  70. package/dist/core/swarm.d.ts +97 -0
  71. package/dist/core/telemetry/index.d.ts +33 -0
  72. package/dist/core/telemetry/index.js +22 -4
  73. package/dist/core/telemetry/sanitize.d.ts +19 -0
  74. package/dist/core/telemetry/types.d.ts +26 -0
  75. package/dist/core/types.d.ts +88 -0
  76. package/dist/core/ui.d.ts +23 -0
  77. package/dist/core/ui.js +0 -5
  78. package/dist/core/validator.d.ts +14 -0
  79. package/dist/core/version.d.ts +1 -0
  80. package/dist/hooks/index.d.ts +106 -0
  81. package/dist/hooks/index.js +349 -0
  82. package/dist/mcp/index.d.ts +58 -0
  83. package/dist/mcp/index.js +295 -0
  84. package/dist/mcp/install.d.ts +52 -0
  85. package/dist/mcp/install.js +204 -0
  86. package/dist/mcp/protocol.d.ts +106 -0
  87. package/dist/mcp/protocol.js +156 -0
  88. package/dist/mcp/tools.d.ts +107 -0
  89. package/dist/mcp/tools.js +621 -0
  90. package/dist/next/index.d.ts +70 -0
  91. package/dist/next/index.js +103 -0
  92. package/dist/plugins/scan.d.ts +85 -0
  93. package/dist/plugins/scan.js +188 -0
  94. package/dist/ui/app.d.ts +11 -0
  95. package/dist/ui/app.js +14 -1
  96. package/dist/vite/index.d.ts +60 -0
  97. package/dist/vite/index.js +56 -0
  98. package/package.json +34 -4
package/dist/cli.js CHANGED
@@ -5,23 +5,27 @@ import * as os from "os";
5
5
  import pc from "picocolors";
6
6
  import { program } from "commander";
7
7
  import { opt, formatHelp } from "./cli/help.js";
8
- import { run } from "./core/orchestrator.js";
9
- import { EventBus } from "./core/eventBus.js";
10
- import { renderTui } from "./ui/app.js";
11
8
  import { initProject } from "./core/init.js";
12
- import { startStudio } from "./core/studio.js";
13
- import { writePrComment } from "./core/pr.js";
14
- import { writeBadge } from "./core/badge.js";
15
- import { writeRegressionSpecs } from "./core/specCompiler.js";
16
- import { flushTelemetry } from "./core/telemetry/index.js";
17
- import { flushCloud } from "./core/cloud/index.js";
18
- import { summarizeFindings } from "./core/summarize.js";
19
- import { applyVerifiedPatches } from "./core/heal/apply.js";
20
- import { openFixPr } from "./core/fixPr.js";
21
- import { promptYesNo, promptInput } from "./core/prompt.js";
22
- import { modernizeFile } from "./core/modernize.js";
23
- import { renderMarkdown } from "./core/renderMarkdown.js";
24
- import { patrol } from "./core/patrol/loop.js";
9
+ import { VERSION } from "./core/version.js";
10
+ import { installHook, runPrePush, uninstallHook } from "./hooks/index.js";
11
+ /**
12
+ * Everything else is loaded on demand, and that is not tidiness — it is 3.3
13
+ * seconds off *every* invocation.
14
+ *
15
+ * Measured cold, on this repo: `ui/app.js` (ink) costs 1645ms and
16
+ * `core/orchestrator.js` (Playwright and the whole heal stack) costs 1710ms. As
17
+ * static imports they are paid before the CLI knows what it was asked to do —
18
+ * so `--help`, `init`, and a pre-push hook whose scan is *skipped* all pay for a
19
+ * browser engine they will never use. On the hook that tax was most of the
20
+ * runtime, which is how a hook gets uninstalled.
21
+ *
22
+ * `await import()` caches after the first call, so the commands that do need
23
+ * these pay exactly what they paid before.
24
+ */
25
+ async function loadOrchestrator() {
26
+ const m = await import("./core/orchestrator.js");
27
+ return m.run;
28
+ }
25
29
  function collect(value, prev) {
26
30
  prev.push(value);
27
31
  return prev;
@@ -31,34 +35,23 @@ function autoWorkers() {
31
35
  const n = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
32
36
  return Math.max(1, Math.min(n, 8));
33
37
  }
34
- /** Auto-detect the running dev server URL: `aztrx.config.ts`, the dev script's
35
- * `--port`, then a probe of common ports. Returns null when nothing responds. */
36
- async function detectUrl(repoRoot) {
37
- const configPath = path.join(repoRoot, "aztrx.config.ts");
38
- if (fs.existsSync(configPath)) {
39
- const m = fs.readFileSync(configPath, "utf-8").match(/url\s*[=:]\s*["']([^"']+)["']/);
40
- if (m)
41
- return m[1];
42
- }
43
- const pkgPath = path.join(repoRoot, "package.json");
44
- if (fs.existsSync(pkgPath)) {
45
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
46
- const dev = pkg.scripts?.dev || pkg.scripts?.start || "";
47
- const pm = dev.match(/(?:--port|-p)\s*[= ]?\s*(\d+)/);
48
- if (pm)
49
- return `http://localhost:${pm[1]}`;
50
- }
51
- for (const port of [3000, 5173, 8080, 3001, 4000, 8000]) {
52
- try {
53
- const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(300) });
54
- if (res.status < 500)
55
- return `http://localhost:${port}`;
56
- }
57
- catch {
58
- // not listening — try the next port
59
- }
38
+ /** Resolve the target for a run with no explicit URL: attach to a dev server
39
+ * that is already up, or boot the project's own one. Prints progress as it goes
40
+ * (this all happens before the TUI mounts, so plain writes are safe) and exits
41
+ * with a specific reason when neither is possible. */
42
+ async function resolveTargetOrExit(repoRoot, noBoot, out = (m) => console.log(m)) {
43
+ const { resolveTarget } = await import("./core/devServer.js");
44
+ const res = await resolveTarget({
45
+ repoRoot,
46
+ allowBoot: !noBoot,
47
+ onBoot: (plan) => out(pc.dim(`No dev server running — starting \`${plan.startCommand}\` (${plan.framework})…`)),
48
+ });
49
+ if (!res.ok) {
50
+ console.error(pc.red(res.error));
51
+ process.exit(1);
60
52
  }
61
- return undefined;
53
+ out(pc.dim(res.detail));
54
+ return { url: res.url, close: res.close };
62
55
  }
63
56
  /** Print one low-key "next flag" hint after a run, so users learn the advanced
64
57
  * flags on demand instead of memorizing the whole surface. Fires only in the
@@ -87,6 +80,9 @@ function suggestNext(findings, opts) {
87
80
  program
88
81
  .name("aztrx-cli")
89
82
  .description("Runtime stress-testing for web apps — detect bugs, prove them with a repro")
83
+ // Read from the installed package.json, so it can never drift from the
84
+ // published version (see core/version.ts).
85
+ .version(VERSION, "-V, --version", "print the CLI version")
90
86
  .option("--repo <path>", "project root to inspect/watch (default: cwd)", process.cwd());
91
87
  program
92
88
  .command("init")
@@ -105,11 +101,111 @@ program
105
101
  console.log("");
106
102
  console.log(pc.dim(`Next: npx aztrx-cli run ${res.url} --repo .`));
107
103
  });
104
+ program
105
+ .command("hook")
106
+ .description("install a git hook that scans your app before you push")
107
+ .argument("<action>", "install | uninstall | run")
108
+ .argument("[name]", "hook name (default: pre-push)")
109
+ .option("--force", "overwrite a pre-push hook that Aztrx did not write")
110
+ .option("--always", "with `run`: scan even when no app code changed")
111
+ .action(async (action, name, opts) => {
112
+ const repoRoot = path.resolve(program.opts().repo);
113
+ const hookName = name ?? "pre-push";
114
+ if (hookName !== "pre-push") {
115
+ console.error(pc.red(`unsupported hook: ${hookName}`) + " (only `pre-push` is wired up)");
116
+ process.exit(1);
117
+ }
118
+ if (action === "install" || action === "uninstall") {
119
+ const res = action === "install" ? installHook(repoRoot, opts.force) : uninstallHook(repoRoot);
120
+ if (!res.ok) {
121
+ console.error(pc.red(`✗ ${res.message}`));
122
+ process.exit(1);
123
+ }
124
+ if (res.action === "absent") {
125
+ console.log(pc.dim(res.message));
126
+ return;
127
+ }
128
+ console.log(pc.green("✓") + ` pre-push hook ${res.message}`);
129
+ if (res.hookPath)
130
+ console.log(pc.dim(` ${path.relative(repoRoot, res.hookPath).replace(/\\/g, "/")}`));
131
+ if (action === "install") {
132
+ console.log(pc.dim(" Every `git push` now scans the app and blocks on a crash."));
133
+ console.log(pc.dim(" Skip one push with `git push --no-verify`, or a run with AZTRX_HOOK_SKIP=1."));
134
+ }
135
+ return;
136
+ }
137
+ if (action !== "run") {
138
+ console.error(pc.red(`unknown action: ${action}`) + " — expected install, uninstall or run");
139
+ process.exit(1);
140
+ }
141
+ // The hook body. Everything is printed at the end rather than streamed: the
142
+ // scan runs with `--json`, which is silent by contract, so there is nothing
143
+ // to stream until it has an answer.
144
+ const res = await runPrePush({
145
+ repoRoot,
146
+ always: opts.always,
147
+ onProgress: (m) => console.log(pc.dim(m)),
148
+ });
149
+ for (const line of res.lines)
150
+ console.log(res.code === 0 ? line : pc.red(line));
151
+ process.exit(res.code);
152
+ });
153
+ program
154
+ .command("mcp")
155
+ .description("serve aztrx to your editor's agent over MCP (stdio), or wire an editor up to it")
156
+ .argument("[action]", "install | uninstall — omit to serve on stdio")
157
+ .option("--force", "with `install`: replace a config file that will not parse (a .bak is saved first)")
158
+ .action(async (action, opts) => {
159
+ const repoRoot = path.resolve(program.opts().repo);
160
+ // No action means serve, because that is what an editor's config runs and it
161
+ // must be the shortest thing to type. From here stdout is the protocol
162
+ // channel: the server reroutes `console.log` to stderr itself, but anything
163
+ // printed *in this branch* would land before that guard exists.
164
+ if (action === undefined) {
165
+ const { startServer } = await import("./mcp/index.js");
166
+ await startServer({ repoRoot });
167
+ return;
168
+ }
169
+ if (action !== "install" && action !== "uninstall") {
170
+ console.error(pc.red(`unknown action: ${action}`) + " — expected install, uninstall, or no action to serve");
171
+ process.exit(1);
172
+ }
173
+ const { installMcp, uninstallMcp } = await import("./mcp/install.js");
174
+ const { VERSION } = await import("./core/version.js");
175
+ const res = action === "install" ? installMcp(repoRoot, VERSION, opts.force) : uninstallMcp(repoRoot);
176
+ let refused = false;
177
+ for (const o of res.outcomes) {
178
+ const line = `${pc.bold(o.target.label)} — ${o.message}`;
179
+ if (o.status === "refused") {
180
+ refused = true;
181
+ console.error(pc.red("✗") + ` ${line}`);
182
+ }
183
+ else if (o.status === "written") {
184
+ console.log(pc.green("✓") + ` ${line}`);
185
+ }
186
+ else {
187
+ // skipped and unchanged are both "nothing to do", not failures.
188
+ console.log(pc.dim(`• ${line}`));
189
+ }
190
+ }
191
+ // A refused config is the one outcome the user must act on — it is the only
192
+ // reason to fail the command. A project with no editor we recognise is not an
193
+ // error, it is a project that does not use one.
194
+ if (refused)
195
+ process.exit(1);
196
+ if (action === "install" && !res.nothingDone) {
197
+ console.log("");
198
+ console.log(pc.dim(" Restart your editor — or reload its window — to pick the server up."));
199
+ console.log(pc.dim(" Scanning and proving a crash need no key. Fixing one does:"));
200
+ console.log(pc.dim(" set ANTHROPIC_API_KEY, or AZTRX_API_BASE + AZTRX_API_KEY + AZTRX_MODEL."));
201
+ }
202
+ });
108
203
  program
109
204
  .command("studio")
110
205
  .description("start the live studio dashboard on localhost:7331")
111
206
  .option("--port <n>", "port to listen on", "7331")
112
- .action((opts) => {
207
+ .action(async (opts) => {
208
+ const { startStudio } = await import("./core/studio.js");
113
209
  startStudio({ repoRoot: path.resolve(program.opts().repo), port: parseInt(opts.port, 10) });
114
210
  });
115
211
  program
@@ -120,6 +216,10 @@ program
120
216
  .action(async (file, opts) => {
121
217
  const repoRoot = path.resolve(program.opts().repo);
122
218
  const rel = path.relative(repoRoot, path.resolve(file));
219
+ const [{ modernizeFile }, { promptYesNo }] = await Promise.all([
220
+ import("./core/modernize.js"),
221
+ import("./core/prompt.js"),
222
+ ]);
123
223
  const res = await modernizeFile(repoRoot, file);
124
224
  if (!res.ok) {
125
225
  console.error(pc.red("modernize failed:") + ` ${res.error}`);
@@ -142,9 +242,10 @@ program
142
242
  .argument("[url]", "dev server to inspect (auto-detected if omitted), e.g. http://localhost:3000")
143
243
  .configureHelp({ formatHelp })
144
244
  .addOption(opt("--repo <path>", "project root to inspect/watch (default: cwd)", "advanced"))
145
- .addOption(opt("--max-actions <n>", "max actions per pass", "advanced").default("100"))
245
+ .addOption(opt("--max-actions <n>", "max actions per pass (default: aztrx.config.ts, else 100)", "advanced"))
146
246
  .addOption(opt("--dry-run", "report what would be clicked without clicking", "detect"))
147
247
  .addOption(opt("--crash-test", "throw a deliberate error to verify capture", "advanced"))
248
+ .addOption(opt("--no-boot", "never start a dev server — only attach to one already running", "detect"))
148
249
  .addOption(opt("--fail-on", "exit 1 if any crash/error finding is present", "ship"))
149
250
  .addOption(opt("--fuzz", "chaos fuzzing instead of the deterministic walk (F5)", "detect"))
150
251
  .addOption(opt("--http-fuzz", "HTTP-layer mutation fuzzing — hostile requests against the target origin (F5-http)", "detect"))
@@ -170,11 +271,11 @@ program
170
271
  .addOption(opt("--lang <en|ru>", "language for the human-language summary", "advanced").default("en"))
171
272
  .addOption(opt("--pr-comment [path]", "write a GitHub PR markdown comment (default .aztrx/pr-comment.md)", "ship"))
172
273
  .addOption(opt("--badge [path]", "write a self-contained SVG badge (default .aztrx/badge.svg)", "ship"))
173
- .addOption(opt("--regression-test [dir]", "copy validated repro specs into the project test dir (default: e2e/ or tests/)", "ship"))
274
+ .addOption(opt("--regression-test [dir]", "copy validated repro specs into the project test dir (default: first of e2e/, tests/, test/, __tests__/; else .aztrx/regression/)", "ship"))
174
275
  .addOption(opt("--telemetry", "opt-in: collect anonymized crash→repro→patch tuples locally (.aztrx/telemetry)", "advanced"))
175
276
  .addOption(opt("--share-data", "opt-in: also upload the sanitized tuples to the telemetry endpoint", "advanced"))
176
277
  .addOption(opt("--upload", "opt-in: stream run results to the Aztrx AI cloud dashboard (needs --api-key)", "advanced"))
177
- .addOption(opt("--api-key <key>", "API key for --upload / --share-data (defaults to $AZTRX_API_KEY)", "advanced"))
278
+ .addOption(opt("--api-key <key>", "API key for --upload / --share-data (defaults to $AZTRX_CLOUD_API_KEY — a separate key from the model provider's $AZTRX_API_KEY)", "advanced"))
178
279
  .addOption(opt("--cloud-url <url>", "override the cloud ingest base URL (default https://api.aztrx.app)", "advanced"))
179
280
  .addOption(opt("--allow-host <host>", "add a host to the network allow-list (repeatable)", "advanced").argParser(collect).default([]))
180
281
  .addOption(opt("--storage-state <path>", "path to a Playwright storage-state JSON (cookies/localStorage) for authenticated pages", "auth"))
@@ -185,19 +286,45 @@ program
185
286
  .addOption(opt("--login-url <url>", "explicit login page URL for --login (default: current page)").hideHelp())
186
287
  .addOption(opt("--plain", "disable the live terminal UI, print plain logs (default when piped)", "advanced"))
187
288
  .addOption(opt("--ui", "force the live terminal UI even when stdout is not a TTY", "advanced"))
289
+ .addOption(opt("--json", "machine-readable result on stdout: one JSON document, nothing else", "advanced"))
188
290
  .action(async (url, opts) => {
189
291
  // `--fix` is the memorable verb; `--magic-fix` is a hidden alias.
190
292
  const magicFix = opts.magicFix || opts.fix;
191
293
  const repoRoot = path.resolve(opts.repo ?? program.opts().repo);
192
- // Auto-detect the target when no URL is given — one less thing to type.
294
+ // Defaults the scaffolded aztrx.config.ts supplies, read here (lazily, like
295
+ // the target resolver) because only this command knows the repo root.
296
+ const { configAllowHosts, configMaxActions } = await import("./core/devServer.js");
297
+ // `--json` is a machine contract: one JSON document on stdout, nothing
298
+ // else. Every human-facing write below is suppressed, and the run itself
299
+ // gets `ui: true` (which already means "emit nothing, the caller renders").
300
+ const json = Boolean(opts.json);
301
+ const out = json ? () => { } : (msg) => console.log(msg);
302
+ // No URL given: find the app, or start it. `--no-boot` keeps the old
303
+ // attach-only behaviour for scripts where a surprise child process is
304
+ // unacceptable.
305
+ let booted;
193
306
  let targetUrl = url;
194
307
  if (!targetUrl) {
195
- targetUrl = await detectUrl(repoRoot);
196
- if (!targetUrl) {
197
- console.error(pc.red("No URL given and none auto-detected. Pass <url>, or run `aztrx-cli init` first."));
198
- process.exit(1);
199
- }
200
- console.log(pc.dim(`Auto-detected ${targetUrl}`));
308
+ const target = await resolveTargetOrExit(repoRoot, opts.boot === false, out);
309
+ targetUrl = target.url;
310
+ booted = target.close;
311
+ }
312
+ // A booted dev server is a child of this process — releasing it is part of
313
+ // finishing, so every exit path goes through here. The `process.exit`
314
+ // calls below would otherwise skip a `finally` block entirely.
315
+ const finish = async (code) => {
316
+ await booted?.().catch(() => { });
317
+ process.exit(code);
318
+ };
319
+ // Spawned by a dev-server plugin (`aztrx-cli/vite`, `aztrx-cli/next`)? The
320
+ // plugins open an IPC channel for exactly this: when the dev server dies,
321
+ // so must the scan — a browser driving an app nobody owns is worse than no
322
+ // scan. `disconnect` is the only reliable signal here, because it also
323
+ // fires when the parent was SIGKILLed, which no signal handler can catch.
324
+ if (process.connected) {
325
+ process.once("disconnect", () => {
326
+ void finish(0);
327
+ });
201
328
  }
202
329
  const workers = opts.workers ? parseInt(opts.workers, 10) : opts.swarm ? autoWorkers() : undefined;
203
330
  const mode = (workers ?? 1) > 1 || opts.httpFuzz
@@ -214,13 +341,18 @@ program
214
341
  let loginEmail = opts.loginEmail ?? process.env.AZTRX_AUTH_EMAIL;
215
342
  let loginPassword = opts.loginPassword ?? process.env.AZTRX_AUTH_PASSWORD;
216
343
  if (opts.login && !loginEmail && !loginPassword) {
344
+ const { promptInput } = await import("./core/prompt.js");
217
345
  loginEmail = await promptInput("Email:");
218
346
  loginPassword = await promptInput("Password:");
219
347
  }
220
348
  const runOpts = {
221
349
  url: targetUrl,
222
350
  repoRoot,
223
- maxActions: parseInt(opts.maxActions, 10),
351
+ // Config-file defaults, with the flag winning: `--max-actions` now has no
352
+ // commander default, so an unset flag is distinguishable from a set one.
353
+ maxActions: opts.maxActions
354
+ ? parseInt(opts.maxActions, 10)
355
+ : (configMaxActions(repoRoot) ?? 100),
224
356
  dryRun: opts.dryRun,
225
357
  crashTest: opts.crashTest,
226
358
  fuzz: opts.fuzz,
@@ -231,7 +363,7 @@ program
231
363
  repro: opts.repro || opts.heal || magicFix,
232
364
  seed: parseInt(opts.seed, 10),
233
365
  workers,
234
- allowHosts: opts.allowHost ?? [],
366
+ allowHosts: [...(opts.allowHost ?? []), ...configAllowHosts(repoRoot)],
235
367
  reproRuns: parseInt(opts.reproRuns, 10),
236
368
  heal: opts.heal || magicFix,
237
369
  healModel: opts.healModel,
@@ -252,9 +384,15 @@ program
252
384
  loginUrl: opts.loginUrl,
253
385
  };
254
386
  const failOn = Boolean(opts.failOn);
255
- const useUi = !opts.plain && (process.stdout.isTTY === true || opts.ui === true);
387
+ const useUi = !json && !opts.plain && (process.stdout.isTTY === true || opts.ui === true);
256
388
  let findings = [];
257
389
  if (useUi) {
390
+ // ink (1645ms) and Playwright (1710ms) — together, and only here.
391
+ const [{ run }, { EventBus }, { renderTui }] = await Promise.all([
392
+ import("./core/orchestrator.js"),
393
+ import("./core/eventBus.js"),
394
+ import("./ui/app.js"),
395
+ ]);
258
396
  const bus = new EventBus();
259
397
  const runPromise = run({ ...runOpts, bus, ui: true });
260
398
  await renderTui({
@@ -269,31 +407,37 @@ program
269
407
  }
270
408
  catch (e) {
271
409
  console.error(pc.red("Aztrx AI run failed:"), e.message);
272
- process.exit(1);
410
+ await finish(1);
273
411
  }
274
412
  }
275
413
  else {
276
- findings = await run(runOpts);
414
+ // `ui: true` also means "print nothing" — reuse it to silence the
415
+ // orchestrator's own banner and progress lines under --json.
416
+ const run = await loadOrchestrator();
417
+ findings = await run(json ? { ...runOpts, ui: true } : runOpts);
277
418
  }
278
419
  if (opts.prComment) {
279
420
  const prPath = typeof opts.prComment === "string"
280
421
  ? opts.prComment
281
422
  : path.join(repoRoot, ".aztrx", "pr-comment.md");
423
+ const { writePrComment } = await import("./core/pr.js");
282
424
  writePrComment(repoRoot, targetUrl, findings, prPath);
283
- console.log(pc.dim(`PR comment: ${path.relative(repoRoot, prPath)}`));
425
+ out(pc.dim(`PR comment: ${path.relative(repoRoot, prPath)}`));
284
426
  }
285
427
  if (opts.badge) {
286
428
  const badgePath = typeof opts.badge === "string"
287
429
  ? opts.badge
288
430
  : path.join(repoRoot, ".aztrx", "badge.svg");
431
+ const { writeBadge } = await import("./core/badge.js");
289
432
  writeBadge(repoRoot, findings, badgePath);
290
- console.log(pc.dim(`Badge: ${path.relative(repoRoot, badgePath)}`));
433
+ out(pc.dim(`Badge: ${path.relative(repoRoot, badgePath)}`));
291
434
  }
292
435
  if (opts.regressionTest) {
293
436
  const regDir = typeof opts.regressionTest === "string" ? opts.regressionTest : undefined;
437
+ const { writeRegressionSpecs } = await import("./core/specCompiler.js");
294
438
  const written = writeRegressionSpecs(repoRoot, findings, regDir);
295
439
  for (const w of written) {
296
- console.log(pc.green(" ✓ regression test") + ` ${path.relative(repoRoot, w)}`);
440
+ out(pc.green(" ✓ regression test") + ` ${path.relative(repoRoot, w)}`);
297
441
  }
298
442
  }
299
443
  // F13 — human-language summary + opt-in apply (the "Senior Rescuer" flow).
@@ -301,49 +445,74 @@ program
301
445
  // under `--fix`, offers to apply the verified patches so `git diff`
302
446
  // shows the result. Never commits.
303
447
  if (magicFix || opts.explain) {
448
+ const [{ summarizeFindings }, { renderMarkdown }] = await Promise.all([
449
+ import("./core/summarize.js"),
450
+ import("./core/renderMarkdown.js"),
451
+ ]);
304
452
  const summary = await summarizeFindings(findings, { lang: opts.lang });
305
- console.log("\n" + renderMarkdown(summary));
453
+ out("\n" + renderMarkdown(summary));
306
454
  }
307
455
  if (magicFix) {
308
456
  const healed = findings.filter((f) => f.heal?.status === "healed");
309
457
  if (healed.length > 0) {
458
+ const { promptYesNo } = await import("./core/prompt.js");
310
459
  const doApply = await promptYesNo(`Apply ${healed.length} verified fix${healed.length === 1 ? "" : "es"} to the working tree? (y/N)`, { yes: opts.yes });
311
460
  if (doApply) {
461
+ const { applyVerifiedPatches } = await import("./core/heal/apply.js");
312
462
  const result = applyVerifiedPatches(repoRoot, findings);
313
463
  for (const a of result.applied) {
314
- console.log(pc.green(" ✓ applied") + ` ${a.filePath} (${a.hunkCount} edit${a.hunkCount === 1 ? "" : "s"})`);
464
+ out(pc.green(" ✓ applied") + ` ${a.filePath} (${a.hunkCount} edit${a.hunkCount === 1 ? "" : "s"})`);
315
465
  }
316
466
  for (const c of result.conflicts) {
317
- console.log(pc.yellow(" ◐ skipped") + ` ${c.filePath}: ${c.error}`);
467
+ out(pc.yellow(" ◐ skipped") + ` ${c.filePath}: ${c.error}`);
318
468
  }
319
- console.log(pc.dim(" Review with: git diff"));
469
+ out(pc.dim(" Review with: git diff"));
320
470
  }
321
471
  else {
322
- console.log(pc.dim(" Not applied — review the .patch files under .aztrx/heal/."));
472
+ out(pc.dim(" Not applied — review the .patch files under .aztrx/heal/."));
323
473
  }
324
474
  }
325
475
  }
326
476
  if (opts.pr) {
477
+ const { openFixPr } = await import("./core/fixPr.js");
327
478
  const prRes = await openFixPr(repoRoot, findings, targetUrl);
328
479
  if (prRes.ok) {
329
- console.log(pc.green(" ✓ PR opened") + ` ${prRes.url}`);
480
+ out(pc.green(" ✓ PR opened") + ` ${prRes.url}`);
330
481
  }
331
482
  else {
332
- console.log(pc.yellow(" ◐ PR skipped") + `: ${prRes.error}`);
483
+ out(pc.yellow(" ◐ PR skipped") + `: ${prRes.error}`);
333
484
  }
334
485
  }
335
486
  // Suggest the next flag (e.g. --fix) after the run, in both the live TUI
336
487
  // and plain paths. In the TUI this prints after the panel has finished.
337
- suggestNext(findings, opts);
488
+ if (!json)
489
+ suggestNext(findings, opts);
338
490
  // Drain any in-flight telemetry uploads (each bounded) before exit, so a
339
491
  // pending `--share-data` dispatch isn't killed mid-flight. Never affects
340
492
  // the exit code.
493
+ const [{ flushTelemetry }, { flushCloud }] = await Promise.all([
494
+ import("./core/telemetry/index.js"),
495
+ import("./core/cloud/index.js"),
496
+ ]);
341
497
  await flushTelemetry();
342
498
  await flushCloud();
499
+ if (json) {
500
+ process.stdout.write(JSON.stringify({
501
+ version: 1,
502
+ url: targetUrl,
503
+ repoRoot,
504
+ counts: {
505
+ crash: findings.filter((f) => f.severity === "crash").length,
506
+ error: findings.filter((f) => f.severity === "error").length,
507
+ warning: findings.filter((f) => f.severity === "warning").length,
508
+ },
509
+ findings,
510
+ }, null, 2) + "\n");
511
+ }
343
512
  if (failOn && findings.some((f) => f.severity === "crash" || f.severity === "error")) {
344
- process.exit(1);
513
+ await finish(1);
345
514
  }
346
- process.exit(0);
515
+ await finish(0);
347
516
  });
348
517
  program
349
518
  .command("patrol")
@@ -357,27 +526,45 @@ program
357
526
  .addOption(opt("--retry-after <s>", "cooldown before an unfixed bug is retried", "advanced").default("1800"))
358
527
  .addOption(opt("--batch", "group all fixes of a cycle into one PR", "advanced"))
359
528
  .addOption(opt("--once", "run a single scan then exit (no loop)", "advanced"))
360
- .addOption(opt("--max-actions <n>", "max actions per pass", "advanced").default("100"))
529
+ .addOption(opt("--max-actions <n>", "max actions per pass (default: aztrx.config.ts, else 100)", "advanced"))
361
530
  .addOption(opt("--fuzz", "chaos fuzzing instead of the deterministic walk", "detect"))
362
531
  .addOption(opt("--workers <n>", "number of parallel detection workers", "detect"))
363
532
  .addOption(opt("--lang <en|ru>", "language for the diagnosis", "advanced").default("en"))
364
- .addOption(opt("--login", "auto-login before each pass", "auth"))
533
+ .addOption(opt("--login", "auto-login before each pass (needs AZTRX_AUTH_EMAIL/AZTRX_AUTH_PASSWORD env)", "auth"))
534
+ .addOption(opt("--login-email <email>", "email for --login (default: $AZTRX_AUTH_EMAIL)").hideHelp())
535
+ .addOption(opt("--login-password <pass>", "password for --login (default: $AZTRX_AUTH_PASSWORD)").hideHelp())
536
+ .addOption(opt("--login-url <url>", "explicit login page URL for --login (default: current page)").hideHelp())
365
537
  .addOption(opt("--storage-state <path>", "Playwright storage-state JSON for authenticated pages", "auth"))
366
538
  .addOption(opt("--heal-model <model>", "LLM model for healing (default: claude-sonnet-5)", "advanced"))
367
539
  .addOption(opt("--test-command <cmd>", "test command run against a healed patch", "advanced"))
368
540
  .addOption(opt("--no-test", "skip the test gate during healing", "advanced"))
369
541
  .addOption(opt("--start-command <cmd>", "command to boot the app for server healing", "advanced"))
542
+ .addOption(opt("--no-boot", "never start a dev server — only attach to one already running", "detect"))
370
543
  .action(async (url, opts) => {
371
544
  const repoRoot = path.resolve(opts.repo ?? program.opts().repo);
545
+ const { configAllowHosts, configMaxActions } = await import("./core/devServer.js");
546
+ let booted;
372
547
  let targetUrl = url;
373
548
  if (!targetUrl) {
374
- targetUrl = await detectUrl(repoRoot);
375
- if (!targetUrl) {
376
- console.error(pc.red("No URL given and none auto-detected. Pass <url>, or run `aztrx-cli init` first."));
377
- process.exit(1);
378
- }
379
- console.log(pc.dim(`Auto-detected ${targetUrl}`));
549
+ const target = await resolveTargetOrExit(repoRoot, opts.boot === false);
550
+ targetUrl = target.url;
551
+ booted = target.close;
552
+ }
553
+ // Resolve credentials the same way `run` does — then refuse if they are
554
+ // incomplete. Patrol is unattended, so it cannot prompt the way `run`
555
+ // does, and `swarm`'s login block needs all three fields and has no else
556
+ // branch: without this, `patrol --login` was accepted and silently did
557
+ // nothing, scanning the logged-out app and opening PRs from those results.
558
+ const loginEmail = opts.loginEmail ?? process.env.AZTRX_AUTH_EMAIL;
559
+ const loginPassword = opts.loginPassword ?? process.env.AZTRX_AUTH_PASSWORD;
560
+ if (opts.login && (!loginEmail || !loginPassword)) {
561
+ console.error("aztrx: --login needs credentials, but none were found.\n" +
562
+ " Set AZTRX_AUTH_EMAIL and AZTRX_AUTH_PASSWORD, or pass --login-email / --login-password.\n" +
563
+ " Refusing to run: an unattended patrol that silently scans the logged-out app " +
564
+ "would open pull requests from the wrong pages.");
565
+ process.exit(2);
380
566
  }
567
+ const { patrol } = await import("./core/patrol/loop.js");
381
568
  await patrol({
382
569
  url: targetUrl,
383
570
  repoRoot,
@@ -387,17 +574,27 @@ program
387
574
  retryAfterMs: parseInt(opts.retryAfter, 10) * 1000,
388
575
  batch: Boolean(opts.batch),
389
576
  once: Boolean(opts.once),
390
- maxActions: parseInt(opts.maxActions, 10),
577
+ maxActions: opts.maxActions
578
+ ? parseInt(opts.maxActions, 10)
579
+ : (configMaxActions(repoRoot) ?? 100),
580
+ // Patrol exposes no --allow-host, so the config file is the only way to
581
+ // allow a cross-origin API here. Absent either, the allow-list stays
582
+ // deny-by-default at the target's own origin.
583
+ allowHosts: configAllowHosts(repoRoot),
391
584
  fuzz: opts.fuzz,
392
585
  workers: opts.workers ? parseInt(opts.workers, 10) : undefined,
393
586
  lang: opts.lang,
394
587
  login: opts.login,
588
+ loginEmail,
589
+ loginPassword,
590
+ loginUrl: opts.loginUrl,
395
591
  storageState: opts.storageState,
396
592
  healModel: opts.healModel,
397
593
  testCommand: opts.testCommand,
398
594
  skipTest: opts.test === false,
399
595
  startCommand: opts.startCommand,
400
596
  });
597
+ await booted?.().catch(() => { });
401
598
  process.exit(0);
402
599
  });
403
600
  program.parseAsync();
@@ -0,0 +1,33 @@
1
+ /**
2
+ * F-auth — auto-login. Locates a login form and drives it with the user's
3
+ * credentials, so the rest of the run (and its repros) exercise the app
4
+ * *authenticated*. Best-effort by design: a login that can't be found or
5
+ * doesn't complete is reported, never fatal — the run continues unauth.
6
+ *
7
+ * The password input is the reliable signal for a login form; the email/username
8
+ * field and submit button are resolved within its nearest `<form>` (or the page
9
+ * body when the app doesn't use a `<form>` element).
10
+ */
11
+ import type { Locator, Page } from "playwright";
12
+ export interface LoginOptions {
13
+ email: string;
14
+ password: string;
15
+ /** Optional explicit login page URL; otherwise the current page is used. */
16
+ loginUrl?: string;
17
+ }
18
+ export interface LoginResult {
19
+ ok: boolean;
20
+ reason?: string;
21
+ }
22
+ export interface LoginForm {
23
+ email: Locator;
24
+ password: Locator;
25
+ submit: Locator | null;
26
+ }
27
+ /** Locate a login form on the current page, or `null` if none is present. */
28
+ export declare function findLoginForm(page: Page): Promise<LoginForm | null>;
29
+ /**
30
+ * Drive the login form. Returns whether it appeared to complete: the password
31
+ * field vanished, or the URL changed. Never throws — every step degrades.
32
+ */
33
+ export declare function establishLogin(page: Page, opts: LoginOptions): Promise<LoginResult>;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Self-contained SVG status badge generated from a real run's findings. Unlike a
3
+ * static "Protected by …" sticker, this badge is *earned*: it reflects the
4
+ * crash/error count of the run that produced it, so a stale badge is a
5
+ * regeneration problem — not a lie baked into the image.
6
+ */
7
+ import type { Finding } from "./types.js";
8
+ export declare function renderBadge(findings: Finding[], label?: string): string;
9
+ export declare function writeBadge(repoRoot: string, findings: Finding[], filePath?: string): string;
@@ -0,0 +1,3 @@
1
+ import { type Browser, type LaunchOptions } from "playwright";
2
+ /** Launch Chromium headlessly, installing its browser build first when absent. */
3
+ export declare function launchChromium(options?: LaunchOptions): Promise<Browser>;