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,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git hook integration — `aztrx-cli hook install | uninstall | run pre-push`.
|
|
3
|
+
*
|
|
4
|
+
* The file that lands in `.git/hooks/` is a short POSIX shim that calls back
|
|
5
|
+
* into the CLI. That split is the design, not a shortcut: a hook carrying its
|
|
6
|
+
* own implementation has to be *reinstalled* to be fixed, and by the time the
|
|
7
|
+
* bug matters the user has already deleted the thing. Here, upgrading
|
|
8
|
+
* `aztrx-cli` upgrades the hook.
|
|
9
|
+
*
|
|
10
|
+
* Everything below is **fail-open**. A missing tool, a shallow clone with no
|
|
11
|
+
* base to diff against, a scan that could not run — none of those are reasons to
|
|
12
|
+
* block someone's push. Only a crash we actually found is. A hook that blocks
|
|
13
|
+
* pushes when it is merely broken gets turned off, and then it catches nothing.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from "child_process";
|
|
16
|
+
import * as fs from "fs";
|
|
17
|
+
import * as path from "path";
|
|
18
|
+
import { spawnScan, summarize } from "../plugins/scan.js";
|
|
19
|
+
/** The line that marks a hook file as ours. Used to decide whether installing
|
|
20
|
+
* is safe (never clobber a hook somebody else wrote) and whether uninstalling
|
|
21
|
+
* would delete something we own. */
|
|
22
|
+
export const HOOK_MARKER = "aztrx-cli hook run pre-push";
|
|
23
|
+
/** The shim that lands in `.git/hooks/pre-push`.
|
|
24
|
+
*
|
|
25
|
+
* Built as an array of lines joined with `\n` rather than one multi-line
|
|
26
|
+
* template literal, and that is load-bearing: a literal's line breaks are the
|
|
27
|
+
* *source file's* line breaks, so on a checkout with `core.autocrlf=true` this
|
|
28
|
+
* would come out CRLF — and `sh` reads `#!/bin/sh\r` as an interpreter named
|
|
29
|
+
* `/bin/sh\r`, which is not a thing. The hook would fail with "bad
|
|
30
|
+
* interpreter", on Windows only, for reasons invisible in the source. */
|
|
31
|
+
export function hookScript() {
|
|
32
|
+
return [
|
|
33
|
+
"#!/bin/sh",
|
|
34
|
+
"# aztrx pre-push hook — installed by `aztrx-cli hook install`.",
|
|
35
|
+
"# Remove with `aztrx-cli hook uninstall`; skip one push with `git push --no-verify`.",
|
|
36
|
+
"#",
|
|
37
|
+
"# This file is a shim. The logic lives in the CLI, so `npm i -g aztrx-cli@latest`",
|
|
38
|
+
"# upgrades this hook too. Edits here will be overwritten by a reinstall.",
|
|
39
|
+
"#",
|
|
40
|
+
"# Fail-open on purpose: if aztrx-cli cannot be found the push proceeds. A missing",
|
|
41
|
+
"# tool must never be able to block someone's push.",
|
|
42
|
+
"if command -v aztrx-cli >/dev/null 2>&1; then",
|
|
43
|
+
' exec aztrx-cli hook run pre-push "$@"',
|
|
44
|
+
"fi",
|
|
45
|
+
"",
|
|
46
|
+
"# Not on PATH — ask npx for the project's own copy. Probe first, then run:",
|
|
47
|
+
"# `npx --no-install` reports 'no such package' and 'the command failed' with the",
|
|
48
|
+
"# same non-zero exit, so `cmd || exit 0` would stay fail-open by also swallowing",
|
|
49
|
+
"# a real crash, and `exit $?` would block a push because the tool is merely",
|
|
50
|
+
"# missing. Neither is acceptable, so find out whether it is there — then run it",
|
|
51
|
+
"# exactly once and let its exit code through untouched.",
|
|
52
|
+
"if npx --no-install aztrx-cli --help >/dev/null 2>&1; then",
|
|
53
|
+
' exec npx --no-install aztrx-cli hook run pre-push "$@"',
|
|
54
|
+
"fi",
|
|
55
|
+
"exit 0",
|
|
56
|
+
"",
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
59
|
+
const ZERO = /^0+$/;
|
|
60
|
+
/** git hands a pre-push hook one line per ref on stdin:
|
|
61
|
+
* `<local ref> <local sha> <remote ref> <remote sha>`. An all-zero sha means
|
|
62
|
+
* "does not exist on that side". Malformed rows are dropped rather than
|
|
63
|
+
* guessed at — a ref we cannot read is a ref we must not silently skip. */
|
|
64
|
+
export function parsePushLines(stdin) {
|
|
65
|
+
return stdin
|
|
66
|
+
.split("\n")
|
|
67
|
+
.map((l) => l.trim())
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.map((l) => l.split(/\s+/))
|
|
70
|
+
.filter((p) => p.length >= 4)
|
|
71
|
+
.map(([localRef, localSha, remoteRef, remoteSha]) => ({
|
|
72
|
+
localRef,
|
|
73
|
+
localSha,
|
|
74
|
+
remoteRef,
|
|
75
|
+
remoteSha,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
/** Deleting a remote branch pushes no code. */
|
|
79
|
+
export function isBranchDelete(ref) {
|
|
80
|
+
return ZERO.test(ref.localSha);
|
|
81
|
+
}
|
|
82
|
+
/** A branch the remote has never seen. There is no base to diff against, so
|
|
83
|
+
* what is new cannot be known — the conservative answer is "all of it". */
|
|
84
|
+
export function isNewBranch(ref) {
|
|
85
|
+
return ZERO.test(ref.remoteSha);
|
|
86
|
+
}
|
|
87
|
+
/** What is this push bringing? Pure but for the injected `diff`, so the branches
|
|
88
|
+
* that are awkward to arrange in a real repo (shallow clone, new branch) are
|
|
89
|
+
* cheap to test. */
|
|
90
|
+
export function analyzePush(refs, diff) {
|
|
91
|
+
// Nothing on stdin: a manual `aztrx-cli hook run pre-push`, or something
|
|
92
|
+
// upstream that swallowed it. Unknown, so scan — which is also the useful
|
|
93
|
+
// thing for someone who typed the command.
|
|
94
|
+
if (refs.length === 0) {
|
|
95
|
+
return { changed: null, liveRefs: [], blind: "git sent no refs on stdin" };
|
|
96
|
+
}
|
|
97
|
+
const liveRefs = refs.filter((r) => !isBranchDelete(r));
|
|
98
|
+
// Deleting branches is a push with no code in it.
|
|
99
|
+
if (liveRefs.length === 0)
|
|
100
|
+
return { changed: [], liveRefs, blind: "" };
|
|
101
|
+
if (liveRefs.some(isNewBranch)) {
|
|
102
|
+
return { changed: null, liveRefs, blind: "the remote has no such branch yet" };
|
|
103
|
+
}
|
|
104
|
+
const files = new Set();
|
|
105
|
+
for (const ref of liveRefs) {
|
|
106
|
+
const names = diff(ref.remoteSha, ref.localSha);
|
|
107
|
+
// A base git cannot resolve (shallow/partial clone, a ref from another
|
|
108
|
+
// machine) is not a reason to skip — it is a reason to look at everything.
|
|
109
|
+
if (names === null) {
|
|
110
|
+
return {
|
|
111
|
+
changed: null,
|
|
112
|
+
liveRefs,
|
|
113
|
+
blind: `git could not diff ${ref.remoteSha.slice(0, 7)}..${ref.localSha.slice(0, 7)}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
for (const n of names)
|
|
117
|
+
files.add(n);
|
|
118
|
+
}
|
|
119
|
+
return { changed: [...files], liveRefs, blind: "" };
|
|
120
|
+
}
|
|
121
|
+
/** Paths that cannot change what the app does at runtime. Kept deliberately
|
|
122
|
+
* short and boring: every entry is a file that, if it changed, no browser could
|
|
123
|
+
* have behaved differently. Anything arguable (lockfiles, config, `.mdx`) is
|
|
124
|
+
* absent on purpose — a wrong skip is a missed crash, and the whole point of the
|
|
125
|
+
* hook is not missing crashes.
|
|
126
|
+
*
|
|
127
|
+
* Git always reports forward slashes, on every platform. */
|
|
128
|
+
const NON_RUNTIME = [
|
|
129
|
+
/^\.github\//i,
|
|
130
|
+
/^\.vscode\//i,
|
|
131
|
+
/^\.idea\//i,
|
|
132
|
+
/^\.aztrx\//i,
|
|
133
|
+
/^aztrx-media\//i,
|
|
134
|
+
/^LICENSE/i,
|
|
135
|
+
/^\.git(ignore|attributes|modules)$/i,
|
|
136
|
+
/^\.editorconfig$/i,
|
|
137
|
+
/^\.npmrc$/i,
|
|
138
|
+
/\.md$/i,
|
|
139
|
+
/\.(png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?)$/i,
|
|
140
|
+
];
|
|
141
|
+
/** Could this file change the app's runtime behaviour? */
|
|
142
|
+
export function isAppCode(file) {
|
|
143
|
+
return !NON_RUNTIME.some((re) => re.test(file));
|
|
144
|
+
}
|
|
145
|
+
export function decidePush(analysis, always) {
|
|
146
|
+
const { changed, blind } = analysis;
|
|
147
|
+
if (always)
|
|
148
|
+
return { scan: true, reason: "scanning every push (--always)" };
|
|
149
|
+
if (changed === null) {
|
|
150
|
+
return { scan: true, reason: `${blind} — scanning the whole app` };
|
|
151
|
+
}
|
|
152
|
+
const code = changed.filter(isAppCode);
|
|
153
|
+
if (code.length === 0) {
|
|
154
|
+
const n = changed.length;
|
|
155
|
+
return {
|
|
156
|
+
scan: false,
|
|
157
|
+
reason: n === 0 ? "nothing changed" : `only docs, metadata or assets changed (${n} file${n === 1 ? "" : "s"})`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const shown = code.slice(0, 3).join(", ") + (code.length > 3 ? `, +${code.length - 3} more` : "");
|
|
161
|
+
return { scan: true, reason: `${code.length} changed file${code.length === 1 ? "" : "s"}: ${shown}` };
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Talking to git and to the filesystem
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
function git(repoRoot, args) {
|
|
167
|
+
const res = spawnSync("git", args, { cwd: repoRoot, encoding: "utf-8" });
|
|
168
|
+
if (res.error || res.status !== 0)
|
|
169
|
+
return { ok: false, out: "" };
|
|
170
|
+
return { ok: true, out: res.stdout };
|
|
171
|
+
}
|
|
172
|
+
/** `git rev-parse --git-path hooks` resolves the hooks directory the way git
|
|
173
|
+
* itself will — through `.git` being a file in a worktree, and through
|
|
174
|
+
* `core.hooksPath` (husky sets it). Installing into `.git/hooks` while
|
|
175
|
+
* `core.hooksPath` points somewhere else produces a hook git never runs, which
|
|
176
|
+
* is the worst possible outcome: the user believes they are covered. */
|
|
177
|
+
export function hooksDir(repoRoot) {
|
|
178
|
+
const res = git(repoRoot, ["rev-parse", "--git-path", "hooks"]);
|
|
179
|
+
if (!res.ok)
|
|
180
|
+
return null;
|
|
181
|
+
const raw = res.out.trim();
|
|
182
|
+
if (!raw)
|
|
183
|
+
return null;
|
|
184
|
+
return path.isAbsolute(raw) ? raw : path.resolve(repoRoot, raw);
|
|
185
|
+
}
|
|
186
|
+
/** Which files a range touches, or null when git cannot answer. */
|
|
187
|
+
export function diffNames(repoRoot, base, head) {
|
|
188
|
+
const res = git(repoRoot, ["diff", "--name-only", base, head]);
|
|
189
|
+
if (!res.ok)
|
|
190
|
+
return null;
|
|
191
|
+
return res.out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
192
|
+
}
|
|
193
|
+
function readStdinSync() {
|
|
194
|
+
try {
|
|
195
|
+
return fs.readFileSync(0, "utf-8");
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// No stdin to read — a manual run from a terminal. Treated as "unknown",
|
|
199
|
+
// which scans; see `analyzePush`.
|
|
200
|
+
return "";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Is this file a shim we wrote (as opposed to a hook the project already had)? */
|
|
204
|
+
export function isOurs(hookPath) {
|
|
205
|
+
try {
|
|
206
|
+
return fs.readFileSync(hookPath, "utf-8").includes(HOOK_MARKER);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
export function installHook(repoRoot, force = false) {
|
|
213
|
+
const dir = hooksDir(repoRoot);
|
|
214
|
+
if (!dir) {
|
|
215
|
+
return { ok: false, action: "refused", message: "not a git repository — nothing to install into." };
|
|
216
|
+
}
|
|
217
|
+
const hookPath = path.join(dir, "pre-push");
|
|
218
|
+
if (fs.existsSync(hookPath) && !isOurs(hookPath) && !force) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
action: "refused",
|
|
222
|
+
hookPath,
|
|
223
|
+
// Overwriting is one thing; overwriting *silently* is how a project loses
|
|
224
|
+
// a hook that ran its linter, and the loss is discovered weeks later.
|
|
225
|
+
message: `${path.relative(repoRoot, hookPath).replace(/\\/g, "/")} already exists and was not written by Aztrx.\n` +
|
|
226
|
+
" Move it aside and re-run, or pass --force to overwrite it.",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const existed = fs.existsSync(hookPath);
|
|
230
|
+
const previous = existed ? fs.readFileSync(hookPath, "utf-8") : "";
|
|
231
|
+
const script = hookScript();
|
|
232
|
+
if (existed && previous === script) {
|
|
233
|
+
return { ok: true, action: "unchanged", hookPath, message: "already installed and up to date." };
|
|
234
|
+
}
|
|
235
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
236
|
+
fs.writeFileSync(hookPath, script, "utf-8");
|
|
237
|
+
try {
|
|
238
|
+
// Windows has no execute bit and git-for-windows runs hooks through its own
|
|
239
|
+
// sh regardless; on POSIX this is what makes the file runnable at all.
|
|
240
|
+
fs.chmodSync(hookPath, 0o755);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
/* a filesystem that cannot do this does not need it */
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
action: isOurs(hookPath) && existed ? "updated" : "installed",
|
|
248
|
+
hookPath,
|
|
249
|
+
message: existed ? "updated." : "installed.",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
export function uninstallHook(repoRoot) {
|
|
253
|
+
const dir = hooksDir(repoRoot);
|
|
254
|
+
if (!dir) {
|
|
255
|
+
return { ok: false, action: "refused", message: "not a git repository." };
|
|
256
|
+
}
|
|
257
|
+
const hookPath = path.join(dir, "pre-push");
|
|
258
|
+
if (!fs.existsSync(hookPath)) {
|
|
259
|
+
return { ok: true, action: "absent", hookPath, message: "no pre-push hook to remove." };
|
|
260
|
+
}
|
|
261
|
+
if (!isOurs(hookPath)) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
action: "refused",
|
|
265
|
+
hookPath,
|
|
266
|
+
message: `${path.relative(repoRoot, hookPath).replace(/\\/g, "/")} was not written by Aztrx — leaving it alone.`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
fs.unlinkSync(hookPath);
|
|
270
|
+
return { ok: true, action: "removed", hookPath, message: "removed." };
|
|
271
|
+
}
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// The hook body
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
/** Hard ceiling on a single hooked scan. It is a safety net for a dev server
|
|
276
|
+
* that never comes up, not a budget — the CLI has its own timeouts. Past this,
|
|
277
|
+
* the push goes through: an infrastructure hang is not a found crash. */
|
|
278
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
279
|
+
const PREFIX = "[aztrx] ";
|
|
280
|
+
function raceTimeout(done, ms, onTimeout) {
|
|
281
|
+
return new Promise((resolve) => {
|
|
282
|
+
const timer = setTimeout(() => {
|
|
283
|
+
onTimeout();
|
|
284
|
+
resolve(false);
|
|
285
|
+
}, ms);
|
|
286
|
+
timer.unref?.();
|
|
287
|
+
void done.then(() => {
|
|
288
|
+
clearTimeout(timer);
|
|
289
|
+
resolve(true);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/** `aztrx-cli hook run pre-push` — the body of the shim. */
|
|
294
|
+
export async function runPrePush(opts) {
|
|
295
|
+
if (process.env.AZTRX_HOOK_SKIP === "1")
|
|
296
|
+
return { code: 0, lines: [] };
|
|
297
|
+
const refs = parsePushLines(opts.stdin ?? readStdinSync());
|
|
298
|
+
const analysis = analyzePush(refs, (base, head) => diffNames(opts.repoRoot, base, head));
|
|
299
|
+
const decision = decidePush(analysis, Boolean(opts.always));
|
|
300
|
+
if (!decision.scan) {
|
|
301
|
+
return { code: 0, lines: [`${PREFIX}skipped — ${decision.reason}.`] };
|
|
302
|
+
}
|
|
303
|
+
opts.onProgress?.(`${PREFIX}${decision.reason}. Scanning the app…`);
|
|
304
|
+
let result;
|
|
305
|
+
let error;
|
|
306
|
+
const handle = spawnScan({
|
|
307
|
+
// No URL and `boot: true`: the child finds the dev server, or starts the
|
|
308
|
+
// project's own and stops it again. It also owns the cleanup, which is why
|
|
309
|
+
// this process can simply exit — see the note on the outcome below.
|
|
310
|
+
repoRoot: opts.repoRoot,
|
|
311
|
+
boot: true,
|
|
312
|
+
onResult: (r) => (result = r),
|
|
313
|
+
onError: (m) => (error = m),
|
|
314
|
+
});
|
|
315
|
+
const timeoutMs = Number(process.env.AZTRX_HOOK_TIMEOUT ?? DEFAULT_TIMEOUT_MS);
|
|
316
|
+
const finished = await raceTimeout(handle.done, Number.isFinite(timeoutMs) ? timeoutMs : DEFAULT_TIMEOUT_MS, () => handle.stop());
|
|
317
|
+
if (!finished) {
|
|
318
|
+
return {
|
|
319
|
+
code: 0,
|
|
320
|
+
lines: [
|
|
321
|
+
`${PREFIX}the scan did not finish in ${Math.round(timeoutMs / 1000)}s — pushing anyway.`,
|
|
322
|
+
`${PREFIX}raise AZTRX_HOOK_TIMEOUT, or run \`npx aztrx-cli\` yourself to see what it is stuck on.`,
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (!result) {
|
|
327
|
+
// Not a clean scan — a scan that never happened. Saying "clean" here would
|
|
328
|
+
// be the one failure mode this tool exists to prevent.
|
|
329
|
+
return {
|
|
330
|
+
code: 0,
|
|
331
|
+
lines: [
|
|
332
|
+
`${PREFIX}could not scan${error ? `: ${error}` : "."}`,
|
|
333
|
+
`${PREFIX}pushing anyway. Run \`npx aztrx-cli\` in this directory to see why.`,
|
|
334
|
+
],
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const summary = summarize(result);
|
|
338
|
+
const blocking = result.counts.crash + result.counts.error;
|
|
339
|
+
if (blocking === 0)
|
|
340
|
+
return { code: 0, lines: [summary] };
|
|
341
|
+
return {
|
|
342
|
+
code: 1,
|
|
343
|
+
lines: [
|
|
344
|
+
summary,
|
|
345
|
+
`${PREFIX}push blocked — ${blocking} crash/error finding${blocking === 1 ? "" : "s"} in your app.`,
|
|
346
|
+
`${PREFIX}fix them and push again; to push anyway, \`git push --no-verify\`.`,
|
|
347
|
+
],
|
|
348
|
+
};
|
|
349
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aztrx-cli mcp` — the server an editor talks to.
|
|
3
|
+
*
|
|
4
|
+
* stdio transport, newline-delimited JSON-RPC, one message per line. That is the
|
|
5
|
+
* whole interface; everything else is in `protocol.ts` (framing and the two
|
|
6
|
+
* protocol eras), `tools.ts` (the three tools) and `install.ts` (writing the
|
|
7
|
+
* editor config).
|
|
8
|
+
*
|
|
9
|
+
* Three things here are load-bearing and easy to get wrong:
|
|
10
|
+
*
|
|
11
|
+
* - **stdout belongs to the protocol.** One stray byte and the client's parser
|
|
12
|
+
* dies on a line it cannot read. The orchestrator is silent under `ui: true`,
|
|
13
|
+
* and `redirectStdoutToStderr` catches anything else that prints.
|
|
14
|
+
* - **Boot fast, import late.** Editors time out slow servers, so nothing heavy
|
|
15
|
+
* is imported at module load. Playwright arrives on the first scan, not on
|
|
16
|
+
* `initialize`.
|
|
17
|
+
* - **Exit when stdin ends.** The stdio shutdown sequence is: the client closes
|
|
18
|
+
* our stdin, waits, then escalates to SIGTERM and SIGKILL. A server that
|
|
19
|
+
* ignores the first step leaves an orphan holding a dev server's port and a
|
|
20
|
+
* Chromium handle after every editor session.
|
|
21
|
+
*/
|
|
22
|
+
import { McpRuntime } from "./tools.js";
|
|
23
|
+
export interface ServerIO {
|
|
24
|
+
input: NodeJS.ReadableStream;
|
|
25
|
+
output: {
|
|
26
|
+
write(chunk: string): unknown;
|
|
27
|
+
};
|
|
28
|
+
error: {
|
|
29
|
+
write(chunk: string): unknown;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export interface StartServerOptions {
|
|
33
|
+
/** Project used when a tool call does not name one. */
|
|
34
|
+
repoRoot?: string;
|
|
35
|
+
/** Test seam. Omitted in production, where the server owns process.stdin and
|
|
36
|
+
* process.stdout and installs the shutdown handlers — a test that installed
|
|
37
|
+
* those would take the test process down with it. */
|
|
38
|
+
io?: ServerIO;
|
|
39
|
+
runtime?: McpRuntime;
|
|
40
|
+
}
|
|
41
|
+
export interface ServerHandle {
|
|
42
|
+
/** Stop serving and release everything this process booted. Idempotent. */
|
|
43
|
+
stop: () => Promise<void>;
|
|
44
|
+
runtime: McpRuntime;
|
|
45
|
+
}
|
|
46
|
+
/** Reroute the console's stdout-bound writers to stderr, for good.
|
|
47
|
+
*
|
|
48
|
+
* `ui: true` already silences the orchestrator, so this is the net under the net:
|
|
49
|
+
* a dependency's warning, or a `console.log` in some path nobody thought about,
|
|
50
|
+
* would otherwise land in the middle of the protocol stream. Nothing is lost by
|
|
51
|
+
* moving it — the spec sanctions stderr for *all* logging on stdio transports.
|
|
52
|
+
* `console.error` and `console.warn` already write to stderr and are left alone.
|
|
53
|
+
*
|
|
54
|
+
* Returns the undo, which the tests use to prove the guard can fail. */
|
|
55
|
+
export declare function redirectStdoutToStderr(target: {
|
|
56
|
+
write(chunk: string): unknown;
|
|
57
|
+
}): () => void;
|
|
58
|
+
export declare function startServer(opts?: StartServerOptions): Promise<ServerHandle>;
|