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