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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Finding, TelemetryErrorPayload } from "./types.js";
|
|
2
|
+
export declare function fingerprintOf(payload: TelemetryErrorPayload): string;
|
|
3
|
+
/**
|
|
4
|
+
* Cross-signal root-cause key. Distinct capture paths for the SAME fault
|
|
5
|
+
* collapse onto one key even though their `type` differs:
|
|
6
|
+
*
|
|
7
|
+
* - a thrown JS error surfaces via `pageerror` (`uncaught_exception`) AND the
|
|
8
|
+
* forwarded `unhandledrejection` hook (`unhandled_rejection`) → keyed by the
|
|
9
|
+
* type-independent fingerprint (normalized message + own frames). The
|
|
10
|
+
* leading `Error:` prefix is normalized away, so the two channels match.
|
|
11
|
+
* - a failed request surfaces via `response` (`network_5xx`), `requestfailed`
|
|
12
|
+
* (`network_timeout`), and the console's "Failed to load resource"
|
|
13
|
+
* (`console_error`) → keyed by the resource URL (for `console_error`, the
|
|
14
|
+
* URL is `msg.location().url`, the failed resource).
|
|
15
|
+
*
|
|
16
|
+
* Falls back to the exact fingerprint when no cross-signal rule applies, so a
|
|
17
|
+
* finding with no natural group still dedups only against itself.
|
|
18
|
+
*/
|
|
19
|
+
export declare function rootKeyOf(payload: TelemetryErrorPayload): string;
|
|
20
|
+
/**
|
|
21
|
+
* F3 — Signal classifier. Fingerprints, dedups, assigns severity, and
|
|
22
|
+
* suppresses a baseline of already-known fingerprints. Returns a Finding on
|
|
23
|
+
* first sight, `null` on duplicate or suppressed.
|
|
24
|
+
*/
|
|
25
|
+
export declare class SignalClassifier {
|
|
26
|
+
private seen;
|
|
27
|
+
private baseline;
|
|
28
|
+
constructor(baselineFingerprints?: string[]);
|
|
29
|
+
classify(payload: TelemetryErrorPayload): Finding | null;
|
|
30
|
+
/** Unique findings, noise suppressed. */
|
|
31
|
+
findings(): Finding[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Collapse findings that share a root cause across capture paths into a single
|
|
35
|
+
* finding. Runs after fingerprint dedup + worker merge; groups by `rootKey`,
|
|
36
|
+
* keeps the richest representative, and sums occurrences. Safe by construction:
|
|
37
|
+
* the key only groups paths that are the same underlying fault (the same thrown
|
|
38
|
+
* error, or the same failing URL), never distinct bugs.
|
|
39
|
+
*/
|
|
40
|
+
export declare function collapseSignals(findings: Finding[]): Finding[];
|
|
41
|
+
export declare function loadBaseline(repoRoot: string): Promise<string[]>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F12 — opt-in cloud sync. Streams a completed run's findings to the Aztrx
|
|
3
|
+
* ingest API (`POST /api/runs`) for the team dashboard and server-side
|
|
4
|
+
* deduplication by crash fingerprint. Mirrors the telemetry module's contract:
|
|
5
|
+
* fire-and-forget, bounded by a short abort, and never affects the exit code.
|
|
6
|
+
* Everything is sanitized before packaging (secrets, URLs, repo paths).
|
|
7
|
+
*/
|
|
8
|
+
import type { Finding } from "../types.js";
|
|
9
|
+
export interface CloudOptions {
|
|
10
|
+
repoRoot: string;
|
|
11
|
+
url: string;
|
|
12
|
+
/** API key presented as `x-api-key` (falls back to `AZTRX_CLOUD_API_KEY`).
|
|
13
|
+
* Deliberately not `AZTRX_API_KEY`: that one is a *model provider*
|
|
14
|
+
* credential (`llm.ts:29,35`), and reusing it here would put an OpenRouter or
|
|
15
|
+
* Anthropic key on the wire as an auth header to the ingest host. */
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
/** Ingest base URL, e.g. `https://api.aztrx.app` (falls back to `AZTRX_CLOUD_URL`). */
|
|
18
|
+
endpoint?: string;
|
|
19
|
+
/** Human-readable run mode, shown in the dashboard. */
|
|
20
|
+
mode: string;
|
|
21
|
+
counts: Record<string, number>;
|
|
22
|
+
}
|
|
23
|
+
export interface CloudFinding {
|
|
24
|
+
fingerprint: string;
|
|
25
|
+
severity: string;
|
|
26
|
+
type: string;
|
|
27
|
+
message: string;
|
|
28
|
+
location?: {
|
|
29
|
+
file: string;
|
|
30
|
+
line: number;
|
|
31
|
+
column: number;
|
|
32
|
+
};
|
|
33
|
+
repro?: {
|
|
34
|
+
verdict: string;
|
|
35
|
+
rate: number;
|
|
36
|
+
runs: number;
|
|
37
|
+
reproductions: number;
|
|
38
|
+
spec: string | null;
|
|
39
|
+
};
|
|
40
|
+
patch?: string | null;
|
|
41
|
+
model_tier?: string | null;
|
|
42
|
+
}
|
|
43
|
+
export interface RunPayload {
|
|
44
|
+
schema: "aztrx.run/1";
|
|
45
|
+
sentAt: string;
|
|
46
|
+
framework: string;
|
|
47
|
+
framework_version?: string;
|
|
48
|
+
target: string;
|
|
49
|
+
mode: string;
|
|
50
|
+
counts: Record<string, number>;
|
|
51
|
+
findings: CloudFinding[];
|
|
52
|
+
}
|
|
53
|
+
/** Fire-and-forget upload. Never rejects; bounded by a short abort. Failures are
|
|
54
|
+
* reported on stderr rather than discarded — silence here reads as success. */
|
|
55
|
+
export declare function dispatchUpload(payload: RunPayload, endpoint: string, apiKey?: string): Promise<void>;
|
|
56
|
+
/** Build the sanitized payload and detach an upload. A clean run (zero
|
|
57
|
+
* findings) still uploads — the dashboard tracks green runs too. */
|
|
58
|
+
export declare function submitRun(findings: Finding[], opts: CloudOptions): void;
|
|
59
|
+
/** Await all in-flight uploads (each already bounded). Called right before the
|
|
60
|
+
* CLI exits so a pending `--upload` isn't killed mid-flight; never affects exit
|
|
61
|
+
* code. */
|
|
62
|
+
export declare function flushCloud(): Promise<void>;
|
package/dist/core/cloud/index.js
CHANGED
|
@@ -6,10 +6,17 @@
|
|
|
6
6
|
* Everything is sanitized before packaging (secrets, URLs, repo paths).
|
|
7
7
|
*/
|
|
8
8
|
import * as fs from "fs";
|
|
9
|
+
import pc from "picocolors";
|
|
9
10
|
import { detectFrameworkMeta } from "../init.js";
|
|
10
11
|
import { createSanitizer } from "../telemetry/sanitize.js";
|
|
11
12
|
const DEFAULT_CLOUD_URL = process.env.AZTRX_CLOUD_URL || "https://api.aztrx.app";
|
|
12
13
|
const UPLOAD_TIMEOUT_MS = 2000;
|
|
14
|
+
/** One dim line on stderr. These uploads are fire-and-forget by design, so this
|
|
15
|
+
* is the only place a failure can surface — and it has to, or an operator who
|
|
16
|
+
* asked for an upload cannot tell a success from a silent 401. */
|
|
17
|
+
function warn(msg) {
|
|
18
|
+
process.stderr.write(pc.dim(`aztrx: ${msg}\n`));
|
|
19
|
+
}
|
|
13
20
|
/** In-flight uploads, drained by `flushCloud()` before the CLI exits. */
|
|
14
21
|
const pendingUploads = [];
|
|
15
22
|
function readFileIfExists(p) {
|
|
@@ -67,7 +74,8 @@ function buildPayload(findings, opts) {
|
|
|
67
74
|
findings: cloudFindings,
|
|
68
75
|
};
|
|
69
76
|
}
|
|
70
|
-
/** Fire-and-forget upload. Never rejects; bounded by a short abort.
|
|
77
|
+
/** Fire-and-forget upload. Never rejects; bounded by a short abort. Failures are
|
|
78
|
+
* reported on stderr rather than discarded — silence here reads as success. */
|
|
71
79
|
export function dispatchUpload(payload, endpoint, apiKey) {
|
|
72
80
|
const ctrl = new AbortController();
|
|
73
81
|
const timer = setTimeout(() => ctrl.abort(), UPLOAD_TIMEOUT_MS);
|
|
@@ -80,15 +88,27 @@ export function dispatchUpload(payload, endpoint, apiKey) {
|
|
|
80
88
|
body: JSON.stringify(payload),
|
|
81
89
|
signal: ctrl.signal,
|
|
82
90
|
})
|
|
83
|
-
.then(() => {
|
|
84
|
-
|
|
91
|
+
.then((res) => {
|
|
92
|
+
// `fetch` resolves on 4xx/5xx too — only the status separates them, so a
|
|
93
|
+
// bare `.then()` here reports a rejected upload as a clean one.
|
|
94
|
+
if (!res.ok)
|
|
95
|
+
warn(`run upload rejected — HTTP ${res.status} from ${endpoint}`);
|
|
96
|
+
})
|
|
97
|
+
.catch((e) => {
|
|
98
|
+
const why = e instanceof Error && e.name === "AbortError"
|
|
99
|
+
? `no response within ${UPLOAD_TIMEOUT_MS}ms`
|
|
100
|
+
: e instanceof Error
|
|
101
|
+
? e.message
|
|
102
|
+
: String(e);
|
|
103
|
+
warn(`run upload failed — ${why}`);
|
|
104
|
+
})
|
|
85
105
|
.finally(() => clearTimeout(timer));
|
|
86
106
|
}
|
|
87
107
|
/** Build the sanitized payload and detach an upload. A clean run (zero
|
|
88
108
|
* findings) still uploads — the dashboard tracks green runs too. */
|
|
89
109
|
export function submitRun(findings, opts) {
|
|
90
110
|
const endpoint = opts.endpoint ?? DEFAULT_CLOUD_URL;
|
|
91
|
-
const apiKey = opts.apiKey ?? process.env.
|
|
111
|
+
const apiKey = opts.apiKey ?? process.env.AZTRX_CLOUD_API_KEY;
|
|
92
112
|
const payload = buildPayload(findings, opts);
|
|
93
113
|
pendingUploads.push(dispatchUpload(payload, endpoint, apiKey));
|
|
94
114
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-config target resolution — what bare `aztrx` does when no URL is given.
|
|
3
|
+
*
|
|
4
|
+
* Every primitive already existed (framework detection in `init.ts`, start-command
|
|
5
|
+
* detection and process-tree booting in `heal/boot.ts`); what was missing is the
|
|
6
|
+
* orchestration that turns "I'm standing in a project directory" into a live URL.
|
|
7
|
+
* The order below is the whole design:
|
|
8
|
+
*
|
|
9
|
+
* 1. a dev server already running — declared in `aztrx.config.ts`, named by the
|
|
10
|
+
* dev script's `--port`, or sitting on the port this project's framework
|
|
11
|
+
* uses — is *attached* to, never killed, because we did not start it
|
|
12
|
+
* 2. otherwise the project's own dev script is booted and killed on exit
|
|
13
|
+
* 3. otherwise the caller gets `ok: false` and prints the pass-a-URL error
|
|
14
|
+
*
|
|
15
|
+
* Only a project we could not identify at all (no dev script, unknown framework)
|
|
16
|
+
* falls back to sweeping the usual ports for a stranger — attaching to the wrong
|
|
17
|
+
* app would report someone else's crashes as yours.
|
|
18
|
+
*
|
|
19
|
+
* Booting the user's app is a bigger liberty than attaching to one, which is why
|
|
20
|
+
* it is opt-out (`--no-boot`) and why the booted child inherits the real
|
|
21
|
+
* environment: it is their machine and their app, and a stripped env would break
|
|
22
|
+
* anything reading `DATABASE_URL`.
|
|
23
|
+
*/
|
|
24
|
+
/** A port hardcoded in the dev script (`vite --port 4000`). It beats the
|
|
25
|
+
* framework default: the script wins over `PORT`, so booting on anything else
|
|
26
|
+
* would leave us polling a port nothing ever binds. */
|
|
27
|
+
export declare function scriptPort(repoRoot: string): number | undefined;
|
|
28
|
+
/** The `allowHosts` entries from a generated `aztrx.config.ts`.
|
|
29
|
+
*
|
|
30
|
+
* `init` scaffolds this key and tells the user to add their API host to it, so
|
|
31
|
+
* it has to actually do something — otherwise following the tool's own
|
|
32
|
+
* instructions changes nothing, and a real app's cross-origin API calls stay
|
|
33
|
+
* refused with no visible reason. Genuinely deny-by-default either way: this
|
|
34
|
+
* only extends the allow-list, and an unparseable value extends it by nothing. */
|
|
35
|
+
export declare function configAllowHosts(repoRoot: string): string[];
|
|
36
|
+
/** The `maxActions` value from a generated `aztrx.config.ts`, when present. */
|
|
37
|
+
export declare function configMaxActions(repoRoot: string): number | undefined;
|
|
38
|
+
/** Poll until the URL answers with any HTTP response (2xx/4xx/5xx all mean the
|
|
39
|
+
* listener is up and serving). */
|
|
40
|
+
export declare function waitForHttp(url: string, timeoutMs: number): Promise<boolean>;
|
|
41
|
+
export interface BootPlan {
|
|
42
|
+
framework: string;
|
|
43
|
+
startCommand: string;
|
|
44
|
+
/** Port to *ask* for. `bootServer` honours it only when it is genuinely free. */
|
|
45
|
+
port: number;
|
|
46
|
+
}
|
|
47
|
+
/** The pure decision at the heart of zero-config: which framework, which command,
|
|
48
|
+
* which port. Null when the project has no `dev`/`start` script — nothing to boot. */
|
|
49
|
+
export declare function planBoot(repoRoot: string): BootPlan | null;
|
|
50
|
+
/** Find a dev server that is already running, so we can attach instead of booting.
|
|
51
|
+
*
|
|
52
|
+
* Declared URLs come first — they are the user's stated intent — then the port
|
|
53
|
+
* this project's own framework listens on. The port check keys off *a listener
|
|
54
|
+
* existing* rather than off an HTTP response, because a server that has bound
|
|
55
|
+
* its port but is still compiling answers nothing yet.
|
|
56
|
+
*
|
|
57
|
+
* The generic sweep of common ports runs **only when we could not work out what
|
|
58
|
+
* this project is** (`plan === null`). That restriction matters: sweeping always
|
|
59
|
+
* would let a Vite project happily attach to whatever unrelated app happens to
|
|
60
|
+
* be sitting on 3000 and report a stranger's crashes as its own. A known project
|
|
61
|
+
* that is not running should be booted, not confused with its neighbour. */
|
|
62
|
+
export declare function findRunning(repoRoot: string, plan: BootPlan | null): Promise<string | null>;
|
|
63
|
+
export type TargetSource = "attached" | "booted";
|
|
64
|
+
export interface ResolvedTarget {
|
|
65
|
+
url: string;
|
|
66
|
+
source: TargetSource;
|
|
67
|
+
/** One line for the user explaining how we got here. */
|
|
68
|
+
detail: string;
|
|
69
|
+
/** Idempotent. A no-op when we attached — we never kill a server we didn't start. */
|
|
70
|
+
close: () => Promise<void>;
|
|
71
|
+
}
|
|
72
|
+
export type TargetResolution = ({
|
|
73
|
+
ok: true;
|
|
74
|
+
} & ResolvedTarget) | {
|
|
75
|
+
ok: false;
|
|
76
|
+
error: string;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Turn "no URL was given" into a live target: attach to a running server, or
|
|
80
|
+
* boot one. Never throws — a boot failure comes back as `{ ok: false, error }`
|
|
81
|
+
* carrying the server's own log tail, which is far more useful than a rejection.
|
|
82
|
+
*/
|
|
83
|
+
export declare function resolveTarget(opts: {
|
|
84
|
+
repoRoot: string;
|
|
85
|
+
/** false = attach only, never spawn (`--no-boot`). */
|
|
86
|
+
allowBoot: boolean;
|
|
87
|
+
/** Called just before a server is spawned — booting can take up to a minute,
|
|
88
|
+
* so the user should see why nothing is happening yet. */
|
|
89
|
+
onBoot?: (plan: BootPlan) => void;
|
|
90
|
+
}): Promise<TargetResolution>;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-config target resolution — what bare `aztrx` does when no URL is given.
|
|
3
|
+
*
|
|
4
|
+
* Every primitive already existed (framework detection in `init.ts`, start-command
|
|
5
|
+
* detection and process-tree booting in `heal/boot.ts`); what was missing is the
|
|
6
|
+
* orchestration that turns "I'm standing in a project directory" into a live URL.
|
|
7
|
+
* The order below is the whole design:
|
|
8
|
+
*
|
|
9
|
+
* 1. a dev server already running — declared in `aztrx.config.ts`, named by the
|
|
10
|
+
* dev script's `--port`, or sitting on the port this project's framework
|
|
11
|
+
* uses — is *attached* to, never killed, because we did not start it
|
|
12
|
+
* 2. otherwise the project's own dev script is booted and killed on exit
|
|
13
|
+
* 3. otherwise the caller gets `ok: false` and prints the pass-a-URL error
|
|
14
|
+
*
|
|
15
|
+
* Only a project we could not identify at all (no dev script, unknown framework)
|
|
16
|
+
* falls back to sweeping the usual ports for a stranger — attaching to the wrong
|
|
17
|
+
* app would report someone else's crashes as yours.
|
|
18
|
+
*
|
|
19
|
+
* Booting the user's app is a bigger liberty than attaching to one, which is why
|
|
20
|
+
* it is opt-out (`--no-boot`) and why the booted child inherits the real
|
|
21
|
+
* environment: it is their machine and their app, and a stripped env would break
|
|
22
|
+
* anything reading `DATABASE_URL`.
|
|
23
|
+
*/
|
|
24
|
+
import * as fs from "fs";
|
|
25
|
+
import * as path from "path";
|
|
26
|
+
import { defaultPort, detectFrameworkMeta } from "./init.js";
|
|
27
|
+
import { bootServer, detectStartCommand, isPortFree } from "./heal/boot.js";
|
|
28
|
+
/** Ports scanned for an already-running app, most common first. */
|
|
29
|
+
const PROBE_PORTS = [3000, 5173, 8080, 3001, 4000, 8000];
|
|
30
|
+
/** How long to wait for a detected server to answer HTTP. Generous on purpose:
|
|
31
|
+
* a cold Next.js or Vite server binds its port long before it can serve, and
|
|
32
|
+
* giving up early would boot a *second* server on top of the user's. */
|
|
33
|
+
const ATTACH_TIMEOUT_MS = 15_000;
|
|
34
|
+
/** Boot can legitimately take a while (dependency scan, cold compile). */
|
|
35
|
+
const BOOT_TIMEOUT_MS = 90_000;
|
|
36
|
+
function readDevScript(repoRoot) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(path.join(repoRoot, "package.json"), "utf-8");
|
|
39
|
+
const s = JSON.parse(raw).scripts;
|
|
40
|
+
if (!s)
|
|
41
|
+
return "";
|
|
42
|
+
if (typeof s.dev === "string")
|
|
43
|
+
return s.dev;
|
|
44
|
+
if (typeof s.start === "string")
|
|
45
|
+
return s.start;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// no package.json, or unparseable — the caller falls through
|
|
49
|
+
}
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
/** A port hardcoded in the dev script (`vite --port 4000`). It beats the
|
|
53
|
+
* framework default: the script wins over `PORT`, so booting on anything else
|
|
54
|
+
* would leave us polling a port nothing ever binds. */
|
|
55
|
+
export function scriptPort(repoRoot) {
|
|
56
|
+
const m = readDevScript(repoRoot).match(/(?:--port|-p)\s*[= ]?\s*(\d+)/);
|
|
57
|
+
return m ? parseInt(m[1], 10) : undefined;
|
|
58
|
+
}
|
|
59
|
+
/** The raw text of a generated `aztrx.config.ts`, or undefined when there is
|
|
60
|
+
* none. Read as text rather than imported — the config is an untrusted
|
|
61
|
+
* TypeScript file, not something to execute just to read a value out of.
|
|
62
|
+
*
|
|
63
|
+
* Every reader below goes through this, so the file is read once per question
|
|
64
|
+
* and they cannot drift into parsing different files. */
|
|
65
|
+
function readConfig(repoRoot) {
|
|
66
|
+
const configPath = path.join(repoRoot, "aztrx.config.ts");
|
|
67
|
+
if (!fs.existsSync(configPath))
|
|
68
|
+
return undefined;
|
|
69
|
+
try {
|
|
70
|
+
return fs.readFileSync(configPath, "utf-8");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** The URL a generated `aztrx.config.ts` declares. */
|
|
77
|
+
function configUrl(repoRoot) {
|
|
78
|
+
const m = readConfig(repoRoot)?.match(/url\s*[=:]\s*["']([^"']+)["']/);
|
|
79
|
+
return m ? m[1] : undefined;
|
|
80
|
+
}
|
|
81
|
+
/** The `allowHosts` entries from a generated `aztrx.config.ts`.
|
|
82
|
+
*
|
|
83
|
+
* `init` scaffolds this key and tells the user to add their API host to it, so
|
|
84
|
+
* it has to actually do something — otherwise following the tool's own
|
|
85
|
+
* instructions changes nothing, and a real app's cross-origin API calls stay
|
|
86
|
+
* refused with no visible reason. Genuinely deny-by-default either way: this
|
|
87
|
+
* only extends the allow-list, and an unparseable value extends it by nothing. */
|
|
88
|
+
export function configAllowHosts(repoRoot) {
|
|
89
|
+
const m = readConfig(repoRoot)?.match(/allowHosts\s*[=:]\s*\[([^\]]*)\]/);
|
|
90
|
+
if (!m)
|
|
91
|
+
return [];
|
|
92
|
+
return [...m[1].matchAll(/["'`]([^"'`]+)["'`]/g)].map((x) => x[1]);
|
|
93
|
+
}
|
|
94
|
+
/** The `maxActions` value from a generated `aztrx.config.ts`, when present. */
|
|
95
|
+
export function configMaxActions(repoRoot) {
|
|
96
|
+
const m = readConfig(repoRoot)?.match(/maxActions\s*[=:]\s*(\d+)/);
|
|
97
|
+
if (!m)
|
|
98
|
+
return undefined;
|
|
99
|
+
const n = parseInt(m[1], 10);
|
|
100
|
+
return Number.isFinite(n) ? n : undefined;
|
|
101
|
+
}
|
|
102
|
+
/** The port a declared URL points at, or null when the host isn't loopback —
|
|
103
|
+
* a remote app's liveness can't be checked with a local connect. */
|
|
104
|
+
function loopbackPort(url) {
|
|
105
|
+
try {
|
|
106
|
+
const u = new URL(url);
|
|
107
|
+
if (!["localhost", "127.0.0.1", "::1", "0.0.0.0"].includes(u.hostname))
|
|
108
|
+
return null;
|
|
109
|
+
if (u.port)
|
|
110
|
+
return parseInt(u.port, 10);
|
|
111
|
+
if (u.protocol === "https:")
|
|
112
|
+
return 443;
|
|
113
|
+
if (u.protocol === "http:")
|
|
114
|
+
return 80;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// not a URL at all — treat it as remote/unprobeable
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
/** Poll until the URL answers with any HTTP response (2xx/4xx/5xx all mean the
|
|
122
|
+
* listener is up and serving). */
|
|
123
|
+
export async function waitForHttp(url, timeoutMs) {
|
|
124
|
+
const deadline = Date.now() + timeoutMs;
|
|
125
|
+
for (;;) {
|
|
126
|
+
try {
|
|
127
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
|
128
|
+
await res.arrayBuffer().catch(() => { });
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
if (Date.now() >= deadline)
|
|
133
|
+
return false;
|
|
134
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** The pure decision at the heart of zero-config: which framework, which command,
|
|
139
|
+
* which port. Null when the project has no `dev`/`start` script — nothing to boot. */
|
|
140
|
+
export function planBoot(repoRoot) {
|
|
141
|
+
const startCommand = detectStartCommand(repoRoot);
|
|
142
|
+
if (!startCommand)
|
|
143
|
+
return null;
|
|
144
|
+
const { framework } = detectFrameworkMeta(repoRoot);
|
|
145
|
+
return {
|
|
146
|
+
framework,
|
|
147
|
+
startCommand,
|
|
148
|
+
port: scriptPort(repoRoot) ?? defaultPort(framework),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Find a dev server that is already running, so we can attach instead of booting.
|
|
152
|
+
*
|
|
153
|
+
* Declared URLs come first — they are the user's stated intent — then the port
|
|
154
|
+
* this project's own framework listens on. The port check keys off *a listener
|
|
155
|
+
* existing* rather than off an HTTP response, because a server that has bound
|
|
156
|
+
* its port but is still compiling answers nothing yet.
|
|
157
|
+
*
|
|
158
|
+
* The generic sweep of common ports runs **only when we could not work out what
|
|
159
|
+
* this project is** (`plan === null`). That restriction matters: sweeping always
|
|
160
|
+
* would let a Vite project happily attach to whatever unrelated app happens to
|
|
161
|
+
* be sitting on 3000 and report a stranger's crashes as its own. A known project
|
|
162
|
+
* that is not running should be booted, not confused with its neighbour. */
|
|
163
|
+
export async function findRunning(repoRoot, plan) {
|
|
164
|
+
const sp = scriptPort(repoRoot);
|
|
165
|
+
const declared = [configUrl(repoRoot), sp ? `http://localhost:${sp}` : undefined].filter((u) => Boolean(u));
|
|
166
|
+
for (const url of declared) {
|
|
167
|
+
const port = loopbackPort(url);
|
|
168
|
+
// A declared URL whose port has no listener is simply stale. Don't make the
|
|
169
|
+
// user sit out the readiness timeout to learn that — go straight to booting.
|
|
170
|
+
if (port !== null && (await isPortFree(port)))
|
|
171
|
+
continue;
|
|
172
|
+
if (await waitForHttp(url, ATTACH_TIMEOUT_MS))
|
|
173
|
+
return url;
|
|
174
|
+
}
|
|
175
|
+
if (plan) {
|
|
176
|
+
const url = `http://127.0.0.1:${plan.port}`;
|
|
177
|
+
if (!(await isPortFree(plan.port)) && (await waitForHttp(url, ATTACH_TIMEOUT_MS)))
|
|
178
|
+
return url;
|
|
179
|
+
return null; // not up — the caller boots it
|
|
180
|
+
}
|
|
181
|
+
for (const port of PROBE_PORTS) {
|
|
182
|
+
if (await isPortFree(port))
|
|
183
|
+
continue;
|
|
184
|
+
const url = `http://localhost:${port}`;
|
|
185
|
+
if (await waitForHttp(url, ATTACH_TIMEOUT_MS))
|
|
186
|
+
return url;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
/** Booting means this process now owns a child dev server, so it also owns
|
|
191
|
+
* killing it. `close` is idempotent, so a signal racing the normal exit path is
|
|
192
|
+
* harmless. Only the booted path arms this — an attached server is not ours. */
|
|
193
|
+
function armSignalCleanup(close) {
|
|
194
|
+
const shutdown = (code) => {
|
|
195
|
+
void Promise.resolve(close()).finally(() => process.exit(code));
|
|
196
|
+
};
|
|
197
|
+
process.once("SIGINT", () => shutdown(130));
|
|
198
|
+
process.once("SIGTERM", () => shutdown(143));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Turn "no URL was given" into a live target: attach to a running server, or
|
|
202
|
+
* boot one. Never throws — a boot failure comes back as `{ ok: false, error }`
|
|
203
|
+
* carrying the server's own log tail, which is far more useful than a rejection.
|
|
204
|
+
*/
|
|
205
|
+
export async function resolveTarget(opts) {
|
|
206
|
+
const { repoRoot, allowBoot } = opts;
|
|
207
|
+
const plan = planBoot(repoRoot);
|
|
208
|
+
const running = await findRunning(repoRoot, plan);
|
|
209
|
+
if (running) {
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
url: running,
|
|
213
|
+
source: "attached",
|
|
214
|
+
detail: `Auto-detected ${running} — attaching (nothing started by Aztrx will be stopped).`,
|
|
215
|
+
close: async () => { },
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (!allowBoot) {
|
|
219
|
+
const hint = plan
|
|
220
|
+
? `Start it yourself, or drop --no-boot to have Aztrx boot \`${plan.startCommand}\`.`
|
|
221
|
+
: "Start it yourself, or pass the URL explicitly.";
|
|
222
|
+
return { ok: false, error: `No dev server is running. ${hint}` };
|
|
223
|
+
}
|
|
224
|
+
if (!plan) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
error: "No dev server is running and package.json has no `dev` or `start` script to boot. Pass <url>, or run `aztrx-cli init` first.",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
opts.onBoot?.(plan);
|
|
231
|
+
try {
|
|
232
|
+
const server = await bootServer({
|
|
233
|
+
worktreeDir: repoRoot,
|
|
234
|
+
repoRoot,
|
|
235
|
+
startCommand: plan.startCommand,
|
|
236
|
+
timeoutMs: BOOT_TIMEOUT_MS,
|
|
237
|
+
port: plan.port,
|
|
238
|
+
// The user's own app, on the user's own machine — it gets the real env.
|
|
239
|
+
env: "inherit",
|
|
240
|
+
});
|
|
241
|
+
armSignalCleanup(server.close);
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
url: server.url,
|
|
245
|
+
source: "booted",
|
|
246
|
+
detail: `Up at ${server.url} — it stops when Aztrx exits.`,
|
|
247
|
+
close: server.close,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
return { ok: false, error: `Could not start \`${plan.startCommand}\`: ${e.message}` };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F14 — the per-finding "diagnosis headline": one sentence that says *why* a
|
|
3
|
+
* crash happened and *what to change*, rendered inline with every crash/error
|
|
4
|
+
* finding (terminal + report.html) on the free, no-key tier.
|
|
5
|
+
*
|
|
6
|
+
* It is deliberately deterministic — keyed on the V8 message shape — so it
|
|
7
|
+
* needs no network round-trip and can never fail the run. The suggested fix
|
|
8
|
+
* mirrors what `--fix` actually applies (optional chaining for null/undefined
|
|
9
|
+
* derefs), so the headline never over-promises a fix it can't deliver.
|
|
10
|
+
*/
|
|
11
|
+
import type { Finding } from "./types.js";
|
|
12
|
+
export type Lang = "en" | "ru";
|
|
13
|
+
/**
|
|
14
|
+
* One-line diagnosis for a finding, or "" when there is nothing actionable to
|
|
15
|
+
* say (noise, or a shape we don't recognize). Crash/error findings only — the
|
|
16
|
+
* headline is advice, and triaged-away noise deserves none.
|
|
17
|
+
*/
|
|
18
|
+
export declare function diagnoseFinding(f: Finding, lang?: string): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal-friendly, word-level diff rendering for healed patches.
|
|
3
|
+
*
|
|
4
|
+
* The heal pipeline already computes Search & Replace hunks (`PatchHunk`). This
|
|
5
|
+
* module turns those hunks into renderable lines — green for added, red for
|
|
6
|
+
* removed, with the exact changed *words* highlighted — so the terminal can show
|
|
7
|
+
* "what changed" the way a human reads a diff (Claude-Code-style) instead of a
|
|
8
|
+
* wall of hunks. Zero dependencies: a small LCS over lines plus a
|
|
9
|
+
* common-prefix/suffix word split.
|
|
10
|
+
*/
|
|
11
|
+
import type { PatchHunk } from "./heal/types.js";
|
|
12
|
+
/** Render-oriented token kind. `ctx` = no highlight (line color only). */
|
|
13
|
+
export type DiffTokenKind = "ctx" | "del" | "add";
|
|
14
|
+
export interface DiffToken {
|
|
15
|
+
text: string;
|
|
16
|
+
kind: DiffTokenKind;
|
|
17
|
+
}
|
|
18
|
+
export interface DiffLine {
|
|
19
|
+
type: "del" | "add";
|
|
20
|
+
tokens: DiffToken[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Line-level LCS diff between two texts, then word-level refinement inside each
|
|
24
|
+
* replacement block. Context (unchanged) lines are omitted — the diff shows
|
|
25
|
+
* only what changed, which is what matters for a fix review.
|
|
26
|
+
*/
|
|
27
|
+
export declare function diffText(oldText: string, newText: string): DiffLine[];
|
|
28
|
+
/** One group of diff lines per hunk (renderers space the groups apart). */
|
|
29
|
+
export declare function diffHunks(hunks: PatchHunk[]): DiffLine[][];
|
|
30
|
+
/** ANSI-colorized diff for the plain (non-TUI) log path. */
|
|
31
|
+
export declare function formatDiff(hunks: PatchHunk[]): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { EventBus } from "./eventBus.js";
|
|
3
|
+
export declare const DESTRUCTIVE: RegExp;
|
|
4
|
+
export declare const TEXT_INPUT_TYPES: Set<string>;
|
|
5
|
+
export declare const SELECTOR = "a, button, input, select, textarea, [role=\"button\"], [onclick]";
|
|
6
|
+
export interface WalkOptions {
|
|
7
|
+
maxActions?: number;
|
|
8
|
+
dryRun?: boolean;
|
|
9
|
+
/** Opt-in: include controls the deny-list skips (delete/pay/logout/checkout…).
|
|
10
|
+
* Off by default — these can mutate real state, so they're refused unless the
|
|
11
|
+
* caller explicitly accepts the risk. */
|
|
12
|
+
allowDestructive?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* F5-lite — discover interactive elements and act on them, tripping runtime
|
|
16
|
+
* errors for the interceptor to catch. This is the deterministic "walk every
|
|
17
|
+
* button" seed of the Chaos Fuzzer; rage-clicks, form fuzzing, and network
|
|
18
|
+
* jitter come next.
|
|
19
|
+
*/
|
|
20
|
+
export declare function walkDom(page: Page, bus: EventBus, opts?: WalkOptions): Promise<{
|
|
21
|
+
actions: number;
|
|
22
|
+
sawLoginForm: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
export declare function originOf(url: string): string;
|
package/dist/core/domWalker.js
CHANGED
|
@@ -26,7 +26,7 @@ export async function walkDom(page, bus, opts = {}) {
|
|
|
26
26
|
if (visited.has(url))
|
|
27
27
|
continue;
|
|
28
28
|
visited.add(url);
|
|
29
|
-
if (page.url()
|
|
29
|
+
if (!samePage(page.url(), url)) {
|
|
30
30
|
await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
31
31
|
await page.waitForTimeout(300);
|
|
32
32
|
}
|
|
@@ -92,7 +92,7 @@ export async function walkDom(page, bus, opts = {}) {
|
|
|
92
92
|
if (!TEXT_INPUT_TYPES.has(type))
|
|
93
93
|
continue; // skip password/hidden/submit/checkbox/etc.
|
|
94
94
|
}
|
|
95
|
-
const selectors = await selectorCascade(
|
|
95
|
+
const selectors = await selectorCascade(handle);
|
|
96
96
|
const signature = selectors.join("|") || `${tag}:${label}`;
|
|
97
97
|
if (seen.has(signature))
|
|
98
98
|
continue; // already acted on this element
|
|
@@ -130,3 +130,19 @@ export async function walkDom(page, bus, opts = {}) {
|
|
|
130
130
|
export function originOf(url) {
|
|
131
131
|
return url.match(/^https?:\/\/[^/]+/)?.[0] ?? "";
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Are these two strings the same page? Compared as parsed URLs, not as text,
|
|
135
|
+
* because the browser normalises what the crawler was handed: a run against
|
|
136
|
+
* `http://localhost:3000` has `page.url() === "http://localhost:3000/"`, so a
|
|
137
|
+
* raw string compare says "different" and the walk re-loads the start page it
|
|
138
|
+
* is already sitting on — once per run, re-firing every mount effect for
|
|
139
|
+
* nothing. `new URL(x).href` puts both sides in the browser's own spelling.
|
|
140
|
+
*/
|
|
141
|
+
function samePage(current, target) {
|
|
142
|
+
try {
|
|
143
|
+
return new URL(current).href === new URL(target).href;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return current === target; // about:blank, or an unparseable href
|
|
147
|
+
}
|
|
148
|
+
}
|