next-anteater 0.2.13 → 0.2.15
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/lib/scaffold.mjs +1282 -1155
- package/package.json +1 -1
package/lib/scaffold.mjs
CHANGED
|
@@ -1,1155 +1,1282 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File scaffolding — generates and writes Anteater files into the target project.
|
|
3
|
-
*/
|
|
4
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
5
|
-
import { join, dirname } from "node:path";
|
|
6
|
-
|
|
7
|
-
async function writeIfNotExists(path, content) {
|
|
8
|
-
try {
|
|
9
|
-
await readFile(path);
|
|
10
|
-
return false; // already exists
|
|
11
|
-
} catch {
|
|
12
|
-
await mkdir(dirname(path), { recursive: true });
|
|
13
|
-
await writeFile(path, content, "utf-8");
|
|
14
|
-
return true;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async function patchRunsRouteDeleteIfMissing(path, isTypeScript) {
|
|
19
|
-
try {
|
|
20
|
-
const existing = await readFile(path, "utf-8");
|
|
21
|
-
if (existing.includes("export async function DELETE(")) {
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
const patched = `${existing.trimEnd()}\n\n${buildRunsDeleteHandler(isTypeScript)}\n`;
|
|
25
|
-
await writeFile(path, patched, "utf-8");
|
|
26
|
-
return true;
|
|
27
|
-
} catch {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function patchApiRouteMutationGuardIfMissing(path) {
|
|
33
|
-
try {
|
|
34
|
-
const existing = await readFile(path, "utf-8");
|
|
35
|
-
if (existing.includes("Same-origin guard for mutating Anteater requests")) {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const guardBlock = [
|
|
40
|
-
" // Same-origin guard for mutating Anteater requests (no app auth integration required)",
|
|
41
|
-
" const requestOrigin = request.nextUrl.origin;",
|
|
42
|
-
' const fetchSite = request.headers.get("sec-fetch-site");',
|
|
43
|
-
' const origin = request.headers.get("origin");',
|
|
44
|
-
' const referer = request.headers.get("referer");',
|
|
45
|
-
" const hasMatchingOrigin = origin === requestOrigin;",
|
|
46
|
-
" const hasMatchingReferer = (() => {",
|
|
47
|
-
" if (!referer) return false;",
|
|
48
|
-
" try {",
|
|
49
|
-
" return new URL(referer).origin === requestOrigin;",
|
|
50
|
-
" } catch {",
|
|
51
|
-
" return false;",
|
|
52
|
-
" }",
|
|
53
|
-
" })();",
|
|
54
|
-
' const hasSameOriginBrowserSignal = fetchSite === "same-origin";',
|
|
55
|
-
" const hasValidSameOriginSignal = hasSameOriginBrowserSignal || hasMatchingOrigin || hasMatchingReferer;",
|
|
56
|
-
" const secret = process.env.ANTEATER_SECRET;",
|
|
57
|
-
' const hasValidSecret = !!secret && request.headers.get("x-anteater-secret") === secret;',
|
|
58
|
-
" if (!hasValidSameOriginSignal && !hasValidSecret) {",
|
|
59
|
-
" return NextResponse.json(",
|
|
60
|
-
' { requestId: "", branch: "", status: "error", error: "Forbidden" },',
|
|
61
|
-
" { status: 403 }",
|
|
62
|
-
" );",
|
|
63
|
-
" }",
|
|
64
|
-
"",
|
|
65
|
-
].join("\n");
|
|
66
|
-
|
|
67
|
-
const contentTypeBlock = [
|
|
68
|
-
' const contentType = request.headers.get("content-type") || "";',
|
|
69
|
-
' if (!contentType.toLowerCase().includes("application/json")) {',
|
|
70
|
-
" return NextResponse.json(",
|
|
71
|
-
' { requestId: "", branch: "", status: "error", error: "Content-Type must be application/json" },',
|
|
72
|
-
" { status: 415 }",
|
|
73
|
-
" );",
|
|
74
|
-
" }",
|
|
75
|
-
"",
|
|
76
|
-
].join("\n");
|
|
77
|
-
|
|
78
|
-
let patched = existing;
|
|
79
|
-
|
|
80
|
-
patched = patched.replace(
|
|
81
|
-
" try {\n const body",
|
|
82
|
-
` try {\n${contentTypeBlock} const body`,
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
const oldAuthPattern = / \/\/ Auth: sec-fetch-site for same-origin \(AnteaterBar\), x-anteater-secret for external[\s\S]*? const repo = getRepo\(\);/;
|
|
86
|
-
if (oldAuthPattern.test(patched)) {
|
|
87
|
-
patched = patched.replace(oldAuthPattern, `${guardBlock} const repo = getRepo();`);
|
|
88
|
-
} else {
|
|
89
|
-
patched = patched.replace(
|
|
90
|
-
" const repo = getRepo();",
|
|
91
|
-
`${guardBlock} const repo = getRepo();`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (patched === existing) {
|
|
96
|
-
return false;
|
|
97
|
-
}
|
|
98
|
-
await writeFile(path, patched, "utf-8");
|
|
99
|
-
return true;
|
|
100
|
-
} catch {
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function patchRunsRouteMutationGuardIfMissing(path) {
|
|
106
|
-
try {
|
|
107
|
-
const existing = await readFile(path, "utf-8");
|
|
108
|
-
if (
|
|
109
|
-
existing.includes("Same-origin guard for mutating runs endpoint") &&
|
|
110
|
-
existing.includes("export async function DELETE(request") &&
|
|
111
|
-
existing.indexOf("Same-origin guard for mutating runs endpoint") >
|
|
112
|
-
existing.indexOf("export async function DELETE(request")
|
|
113
|
-
) {
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const guardBlock = [
|
|
118
|
-
" // Same-origin guard for mutating runs endpoint (no app auth integration required)",
|
|
119
|
-
" const requestOrigin = new URL(request.url).origin;",
|
|
120
|
-
' const fetchSite = request.headers.get("sec-fetch-site");',
|
|
121
|
-
' const origin = request.headers.get("origin");',
|
|
122
|
-
' const referer = request.headers.get("referer");',
|
|
123
|
-
" const hasMatchingOrigin = origin === requestOrigin;",
|
|
124
|
-
" const hasMatchingReferer = (() => {",
|
|
125
|
-
" if (!referer) return false;",
|
|
126
|
-
" try {",
|
|
127
|
-
" return new URL(referer).origin === requestOrigin;",
|
|
128
|
-
" } catch {",
|
|
129
|
-
" return false;",
|
|
130
|
-
" }",
|
|
131
|
-
" })();",
|
|
132
|
-
' const hasSameOriginBrowserSignal = fetchSite === "same-origin";',
|
|
133
|
-
" const hasValidSameOriginSignal = hasSameOriginBrowserSignal || hasMatchingOrigin || hasMatchingReferer;",
|
|
134
|
-
" const secret = process.env.ANTEATER_SECRET;",
|
|
135
|
-
' const hasValidSecret = !!secret && request.headers.get("x-anteater-secret") === secret;',
|
|
136
|
-
" if (!hasValidSameOriginSignal && !hasValidSecret) {",
|
|
137
|
-
' return NextResponse.json({ error: "Forbidden" }, { status: 403 });',
|
|
138
|
-
" }",
|
|
139
|
-
"",
|
|
140
|
-
].join("\n");
|
|
141
|
-
|
|
142
|
-
const deleteFnPattern =
|
|
143
|
-
/(export async function DELETE\(request[^\n]*\) \{[\s\S]*?if \(!requestId\) \{[\s\S]*?\n \}\n\s*)/;
|
|
144
|
-
let patched = existing;
|
|
145
|
-
|
|
146
|
-
if (deleteFnPattern.test(existing)) {
|
|
147
|
-
patched = existing.replace(deleteFnPattern, `$1${guardBlock}`);
|
|
148
|
-
} else {
|
|
149
|
-
return false;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Clean up buggy older patch where guard was accidentally inserted in GET.
|
|
153
|
-
const getGuardPattern =
|
|
154
|
-
/(export async function GET\(\) \{[\s\S]*?)\n \/\/ Same-origin guard for mutating runs endpoint[\s\S]*? const gh = \(url/g;
|
|
155
|
-
if (getGuardPattern.test(patched)) {
|
|
156
|
-
patched = patched.replace(getGuardPattern, "$1\n const gh = (url");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (patched === existing) {
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
await writeFile(path, patched, "utf-8");
|
|
163
|
-
return true;
|
|
164
|
-
} catch {
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async function patchRunsRouteFailedTtlIfMissing(path) {
|
|
170
|
-
try {
|
|
171
|
-
const existing = await readFile(path, "utf-8");
|
|
172
|
-
if (existing.includes("failedCutoffMs")) {
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const replacement = [
|
|
177
|
-
" // Sort newest first, cap at 5",
|
|
178
|
-
" // Drop stale failed runs (>1h) so old errors don't clutter the bar",
|
|
179
|
-
" const failedCutoffMs = 60 * 60 * 1000;",
|
|
180
|
-
" const freshRuns = runs.filter((r) => {",
|
|
181
|
-
' if (r.step !== "error") return true;',
|
|
182
|
-
" const startedAtMs = new Date(r.startedAt).getTime();",
|
|
183
|
-
" if (Number.isNaN(startedAtMs)) return true;",
|
|
184
|
-
" return Date.now() - startedAtMs <= failedCutoffMs;",
|
|
185
|
-
" });",
|
|
186
|
-
"",
|
|
187
|
-
" freshRuns.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());",
|
|
188
|
-
"",
|
|
189
|
-
' return NextResponse.json' + (existing.includes("<AnteaterRunsResponse>") ? "<AnteaterRunsResponse>" : "") + "(",
|
|
190
|
-
" { runs: freshRuns.slice(0, 5), deploymentId: process.env.VERCEL_DEPLOYMENT_ID }",
|
|
191
|
-
" );",
|
|
192
|
-
].join("\n");
|
|
193
|
-
|
|
194
|
-
const sortAndReturnPattern =
|
|
195
|
-
/ \/\/ Sort newest first, cap at 5[\s\S]*? return NextResponse\.json(?:<AnteaterRunsResponse>)?\([\s\S]*?\n \);/;
|
|
196
|
-
if (!sortAndReturnPattern.test(existing)) {
|
|
197
|
-
return false;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const patched = existing.replace(sortAndReturnPattern, replacement);
|
|
201
|
-
if (patched === existing) {
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
await writeFile(path, patched, "utf-8");
|
|
205
|
-
return true;
|
|
206
|
-
} catch {
|
|
207
|
-
return false;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
add(
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
add("");
|
|
354
|
-
add(
|
|
355
|
-
add("
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
add("");
|
|
359
|
-
add("
|
|
360
|
-
add("
|
|
361
|
-
add("
|
|
362
|
-
add(
|
|
363
|
-
add("
|
|
364
|
-
add("
|
|
365
|
-
add(
|
|
366
|
-
add(
|
|
367
|
-
add("
|
|
368
|
-
add("
|
|
369
|
-
add(
|
|
370
|
-
add("
|
|
371
|
-
add("
|
|
372
|
-
add(
|
|
373
|
-
add('
|
|
374
|
-
add("
|
|
375
|
-
add('
|
|
376
|
-
add(
|
|
377
|
-
add("
|
|
378
|
-
add("
|
|
379
|
-
add("
|
|
380
|
-
add("
|
|
381
|
-
add("");
|
|
382
|
-
add("
|
|
383
|
-
add("
|
|
384
|
-
add("
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
add("
|
|
388
|
-
add("
|
|
389
|
-
add("");
|
|
390
|
-
add(
|
|
391
|
-
add("
|
|
392
|
-
add(
|
|
393
|
-
add(
|
|
394
|
-
add("
|
|
395
|
-
add("
|
|
396
|
-
add("
|
|
397
|
-
add("
|
|
398
|
-
add("");
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
add("
|
|
402
|
-
add("
|
|
403
|
-
add("
|
|
404
|
-
add("
|
|
405
|
-
add("
|
|
406
|
-
add("
|
|
407
|
-
add("
|
|
408
|
-
add('
|
|
409
|
-
add("
|
|
410
|
-
add("");
|
|
411
|
-
add("
|
|
412
|
-
add("
|
|
413
|
-
add("
|
|
414
|
-
add(
|
|
415
|
-
add("
|
|
416
|
-
add("");
|
|
417
|
-
add("
|
|
418
|
-
add("
|
|
419
|
-
add("
|
|
420
|
-
add("
|
|
421
|
-
add("
|
|
422
|
-
add("
|
|
423
|
-
add(
|
|
424
|
-
add("
|
|
425
|
-
add("
|
|
426
|
-
add("
|
|
427
|
-
add(" }");
|
|
428
|
-
add("
|
|
429
|
-
add(
|
|
430
|
-
add("
|
|
431
|
-
add("
|
|
432
|
-
add("
|
|
433
|
-
add("
|
|
434
|
-
add("");
|
|
435
|
-
add("
|
|
436
|
-
add("
|
|
437
|
-
add("
|
|
438
|
-
add("
|
|
439
|
-
add("
|
|
440
|
-
add("
|
|
441
|
-
add("");
|
|
442
|
-
add("
|
|
443
|
-
add(" `
|
|
444
|
-
add("
|
|
445
|
-
add("
|
|
446
|
-
add("
|
|
447
|
-
add("
|
|
448
|
-
add('
|
|
449
|
-
add("
|
|
450
|
-
add("
|
|
451
|
-
add(
|
|
452
|
-
add('
|
|
453
|
-
add("
|
|
454
|
-
add("
|
|
455
|
-
add("");
|
|
456
|
-
add("
|
|
457
|
-
add("
|
|
458
|
-
add(
|
|
459
|
-
add("
|
|
460
|
-
add("
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
};
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
add
|
|
747
|
-
|
|
748
|
-
add("
|
|
749
|
-
add(
|
|
750
|
-
add("
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
add("
|
|
754
|
-
add("
|
|
755
|
-
add("");
|
|
756
|
-
add("
|
|
757
|
-
add("
|
|
758
|
-
add("
|
|
759
|
-
add("
|
|
760
|
-
add("
|
|
761
|
-
add("
|
|
762
|
-
add("
|
|
763
|
-
add("
|
|
764
|
-
add("
|
|
765
|
-
add("");
|
|
766
|
-
add("
|
|
767
|
-
add("
|
|
768
|
-
add("
|
|
769
|
-
add("
|
|
770
|
-
add("");
|
|
771
|
-
add("
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
add("
|
|
775
|
-
add("
|
|
776
|
-
add("
|
|
777
|
-
add("");
|
|
778
|
-
add("
|
|
779
|
-
add("
|
|
780
|
-
add("
|
|
781
|
-
add("
|
|
782
|
-
add("
|
|
783
|
-
add("
|
|
784
|
-
add("
|
|
785
|
-
add("
|
|
786
|
-
add(
|
|
787
|
-
add("
|
|
788
|
-
add("
|
|
789
|
-
add("
|
|
790
|
-
add("
|
|
791
|
-
add("
|
|
792
|
-
add("");
|
|
793
|
-
add("
|
|
794
|
-
add("
|
|
795
|
-
add("
|
|
796
|
-
add("
|
|
797
|
-
add("
|
|
798
|
-
add("
|
|
799
|
-
add("
|
|
800
|
-
add("
|
|
801
|
-
add("
|
|
802
|
-
add("
|
|
803
|
-
add("
|
|
804
|
-
add("
|
|
805
|
-
add("");
|
|
806
|
-
add("
|
|
807
|
-
add("
|
|
808
|
-
add("
|
|
809
|
-
add("
|
|
810
|
-
add(" if (
|
|
811
|
-
add("
|
|
812
|
-
add("
|
|
813
|
-
add("
|
|
814
|
-
add("
|
|
815
|
-
add("");
|
|
816
|
-
add("
|
|
817
|
-
add("");
|
|
818
|
-
add("
|
|
819
|
-
add("
|
|
820
|
-
add("
|
|
821
|
-
add("
|
|
822
|
-
add("
|
|
823
|
-
add("
|
|
824
|
-
add("}");
|
|
825
|
-
add("");
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
add("");
|
|
840
|
-
add("
|
|
841
|
-
add("
|
|
842
|
-
add("
|
|
843
|
-
add("
|
|
844
|
-
add("
|
|
845
|
-
add("
|
|
846
|
-
add("
|
|
847
|
-
add("");
|
|
848
|
-
add("
|
|
849
|
-
add("
|
|
850
|
-
add("
|
|
851
|
-
add("
|
|
852
|
-
add("
|
|
853
|
-
add("");
|
|
854
|
-
add("
|
|
855
|
-
add("
|
|
856
|
-
add(
|
|
857
|
-
add(
|
|
858
|
-
add(
|
|
859
|
-
add("
|
|
860
|
-
add("
|
|
861
|
-
add("
|
|
862
|
-
add("
|
|
863
|
-
add("
|
|
864
|
-
add("
|
|
865
|
-
add("
|
|
866
|
-
add("
|
|
867
|
-
add("
|
|
868
|
-
add(
|
|
869
|
-
add("
|
|
870
|
-
add("
|
|
871
|
-
add(
|
|
872
|
-
add("
|
|
873
|
-
add(
|
|
874
|
-
add("
|
|
875
|
-
add("");
|
|
876
|
-
add("
|
|
877
|
-
add("
|
|
878
|
-
add("
|
|
879
|
-
add("
|
|
880
|
-
add("
|
|
881
|
-
add(
|
|
882
|
-
add(
|
|
883
|
-
add("
|
|
884
|
-
add("
|
|
885
|
-
add("
|
|
886
|
-
add("");
|
|
887
|
-
add("
|
|
888
|
-
add("
|
|
889
|
-
add("
|
|
890
|
-
add("
|
|
891
|
-
add("
|
|
892
|
-
add("
|
|
893
|
-
add("
|
|
894
|
-
add("
|
|
895
|
-
add("");
|
|
896
|
-
add("
|
|
897
|
-
add("
|
|
898
|
-
add("
|
|
899
|
-
add("
|
|
900
|
-
add("");
|
|
901
|
-
add(" if (
|
|
902
|
-
add("
|
|
903
|
-
add("
|
|
904
|
-
add("");
|
|
905
|
-
add("
|
|
906
|
-
add("
|
|
907
|
-
add("
|
|
908
|
-
add("
|
|
909
|
-
add("");
|
|
910
|
-
add("
|
|
911
|
-
add("
|
|
912
|
-
add("
|
|
913
|
-
add("
|
|
914
|
-
add("
|
|
915
|
-
add("");
|
|
916
|
-
add("
|
|
917
|
-
add("
|
|
918
|
-
add("
|
|
919
|
-
add("");
|
|
920
|
-
add("
|
|
921
|
-
add("
|
|
922
|
-
add("
|
|
923
|
-
add("
|
|
924
|
-
add("}");
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
const
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
},
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
});
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
//
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
)
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* File scaffolding — generates and writes Anteater files into the target project.
|
|
3
|
+
*/
|
|
4
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
|
|
7
|
+
async function writeIfNotExists(path, content) {
|
|
8
|
+
try {
|
|
9
|
+
await readFile(path);
|
|
10
|
+
return false; // already exists
|
|
11
|
+
} catch {
|
|
12
|
+
await mkdir(dirname(path), { recursive: true });
|
|
13
|
+
await writeFile(path, content, "utf-8");
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function patchRunsRouteDeleteIfMissing(path, isTypeScript) {
|
|
19
|
+
try {
|
|
20
|
+
const existing = await readFile(path, "utf-8");
|
|
21
|
+
if (existing.includes("export async function DELETE(")) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const patched = `${existing.trimEnd()}\n\n${buildRunsDeleteHandler(isTypeScript)}\n`;
|
|
25
|
+
await writeFile(path, patched, "utf-8");
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function patchApiRouteMutationGuardIfMissing(path) {
|
|
33
|
+
try {
|
|
34
|
+
const existing = await readFile(path, "utf-8");
|
|
35
|
+
if (existing.includes("Same-origin guard for mutating Anteater requests")) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const guardBlock = [
|
|
40
|
+
" // Same-origin guard for mutating Anteater requests (no app auth integration required)",
|
|
41
|
+
" const requestOrigin = request.nextUrl.origin;",
|
|
42
|
+
' const fetchSite = request.headers.get("sec-fetch-site");',
|
|
43
|
+
' const origin = request.headers.get("origin");',
|
|
44
|
+
' const referer = request.headers.get("referer");',
|
|
45
|
+
" const hasMatchingOrigin = origin === requestOrigin;",
|
|
46
|
+
" const hasMatchingReferer = (() => {",
|
|
47
|
+
" if (!referer) return false;",
|
|
48
|
+
" try {",
|
|
49
|
+
" return new URL(referer).origin === requestOrigin;",
|
|
50
|
+
" } catch {",
|
|
51
|
+
" return false;",
|
|
52
|
+
" }",
|
|
53
|
+
" })();",
|
|
54
|
+
' const hasSameOriginBrowserSignal = fetchSite === "same-origin";',
|
|
55
|
+
" const hasValidSameOriginSignal = hasSameOriginBrowserSignal || hasMatchingOrigin || hasMatchingReferer;",
|
|
56
|
+
" const secret = process.env.ANTEATER_SECRET;",
|
|
57
|
+
' const hasValidSecret = !!secret && request.headers.get("x-anteater-secret") === secret;',
|
|
58
|
+
" if (!hasValidSameOriginSignal && !hasValidSecret) {",
|
|
59
|
+
" return NextResponse.json(",
|
|
60
|
+
' { requestId: "", branch: "", status: "error", error: "Forbidden" },',
|
|
61
|
+
" { status: 403 }",
|
|
62
|
+
" );",
|
|
63
|
+
" }",
|
|
64
|
+
"",
|
|
65
|
+
].join("\n");
|
|
66
|
+
|
|
67
|
+
const contentTypeBlock = [
|
|
68
|
+
' const contentType = request.headers.get("content-type") || "";',
|
|
69
|
+
' if (!contentType.toLowerCase().includes("application/json")) {',
|
|
70
|
+
" return NextResponse.json(",
|
|
71
|
+
' { requestId: "", branch: "", status: "error", error: "Content-Type must be application/json" },',
|
|
72
|
+
" { status: 415 }",
|
|
73
|
+
" );",
|
|
74
|
+
" }",
|
|
75
|
+
"",
|
|
76
|
+
].join("\n");
|
|
77
|
+
|
|
78
|
+
let patched = existing;
|
|
79
|
+
|
|
80
|
+
patched = patched.replace(
|
|
81
|
+
" try {\n const body",
|
|
82
|
+
` try {\n${contentTypeBlock} const body`,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const oldAuthPattern = / \/\/ Auth: sec-fetch-site for same-origin \(AnteaterBar\), x-anteater-secret for external[\s\S]*? const repo = getRepo\(\);/;
|
|
86
|
+
if (oldAuthPattern.test(patched)) {
|
|
87
|
+
patched = patched.replace(oldAuthPattern, `${guardBlock} const repo = getRepo();`);
|
|
88
|
+
} else {
|
|
89
|
+
patched = patched.replace(
|
|
90
|
+
" const repo = getRepo();",
|
|
91
|
+
`${guardBlock} const repo = getRepo();`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (patched === existing) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
await writeFile(path, patched, "utf-8");
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function patchRunsRouteMutationGuardIfMissing(path) {
|
|
106
|
+
try {
|
|
107
|
+
const existing = await readFile(path, "utf-8");
|
|
108
|
+
if (
|
|
109
|
+
existing.includes("Same-origin guard for mutating runs endpoint") &&
|
|
110
|
+
existing.includes("export async function DELETE(request") &&
|
|
111
|
+
existing.indexOf("Same-origin guard for mutating runs endpoint") >
|
|
112
|
+
existing.indexOf("export async function DELETE(request")
|
|
113
|
+
) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const guardBlock = [
|
|
118
|
+
" // Same-origin guard for mutating runs endpoint (no app auth integration required)",
|
|
119
|
+
" const requestOrigin = new URL(request.url).origin;",
|
|
120
|
+
' const fetchSite = request.headers.get("sec-fetch-site");',
|
|
121
|
+
' const origin = request.headers.get("origin");',
|
|
122
|
+
' const referer = request.headers.get("referer");',
|
|
123
|
+
" const hasMatchingOrigin = origin === requestOrigin;",
|
|
124
|
+
" const hasMatchingReferer = (() => {",
|
|
125
|
+
" if (!referer) return false;",
|
|
126
|
+
" try {",
|
|
127
|
+
" return new URL(referer).origin === requestOrigin;",
|
|
128
|
+
" } catch {",
|
|
129
|
+
" return false;",
|
|
130
|
+
" }",
|
|
131
|
+
" })();",
|
|
132
|
+
' const hasSameOriginBrowserSignal = fetchSite === "same-origin";',
|
|
133
|
+
" const hasValidSameOriginSignal = hasSameOriginBrowserSignal || hasMatchingOrigin || hasMatchingReferer;",
|
|
134
|
+
" const secret = process.env.ANTEATER_SECRET;",
|
|
135
|
+
' const hasValidSecret = !!secret && request.headers.get("x-anteater-secret") === secret;',
|
|
136
|
+
" if (!hasValidSameOriginSignal && !hasValidSecret) {",
|
|
137
|
+
' return NextResponse.json({ error: "Forbidden" }, { status: 403 });',
|
|
138
|
+
" }",
|
|
139
|
+
"",
|
|
140
|
+
].join("\n");
|
|
141
|
+
|
|
142
|
+
const deleteFnPattern =
|
|
143
|
+
/(export async function DELETE\(request[^\n]*\) \{[\s\S]*?if \(!requestId\) \{[\s\S]*?\n \}\n\s*)/;
|
|
144
|
+
let patched = existing;
|
|
145
|
+
|
|
146
|
+
if (deleteFnPattern.test(existing)) {
|
|
147
|
+
patched = existing.replace(deleteFnPattern, `$1${guardBlock}`);
|
|
148
|
+
} else {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Clean up buggy older patch where guard was accidentally inserted in GET.
|
|
153
|
+
const getGuardPattern =
|
|
154
|
+
/(export async function GET\(\) \{[\s\S]*?)\n \/\/ Same-origin guard for mutating runs endpoint[\s\S]*? const gh = \(url/g;
|
|
155
|
+
if (getGuardPattern.test(patched)) {
|
|
156
|
+
patched = patched.replace(getGuardPattern, "$1\n const gh = (url");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (patched === existing) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
await writeFile(path, patched, "utf-8");
|
|
163
|
+
return true;
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function patchRunsRouteFailedTtlIfMissing(path) {
|
|
170
|
+
try {
|
|
171
|
+
const existing = await readFile(path, "utf-8");
|
|
172
|
+
if (existing.includes("failedCutoffMs")) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const replacement = [
|
|
177
|
+
" // Sort newest first, cap at 5",
|
|
178
|
+
" // Drop stale failed runs (>1h) so old errors don't clutter the bar",
|
|
179
|
+
" const failedCutoffMs = 60 * 60 * 1000;",
|
|
180
|
+
" const freshRuns = runs.filter((r) => {",
|
|
181
|
+
' if (r.step !== "error") return true;',
|
|
182
|
+
" const startedAtMs = new Date(r.startedAt).getTime();",
|
|
183
|
+
" if (Number.isNaN(startedAtMs)) return true;",
|
|
184
|
+
" return Date.now() - startedAtMs <= failedCutoffMs;",
|
|
185
|
+
" });",
|
|
186
|
+
"",
|
|
187
|
+
" freshRuns.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());",
|
|
188
|
+
"",
|
|
189
|
+
' return NextResponse.json' + (existing.includes("<AnteaterRunsResponse>") ? "<AnteaterRunsResponse>" : "") + "(",
|
|
190
|
+
" { runs: freshRuns.slice(0, 5), deploymentId: process.env.VERCEL_DEPLOYMENT_ID }",
|
|
191
|
+
" );",
|
|
192
|
+
].join("\n");
|
|
193
|
+
|
|
194
|
+
const sortAndReturnPattern =
|
|
195
|
+
/ \/\/ Sort newest first, cap at 5[\s\S]*? return NextResponse\.json(?:<AnteaterRunsResponse>)?\([\s\S]*?\n \);/;
|
|
196
|
+
if (!sortAndReturnPattern.test(existing)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const patched = existing.replace(sortAndReturnPattern, replacement);
|
|
201
|
+
if (patched === existing) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
await writeFile(path, patched, "utf-8");
|
|
205
|
+
return true;
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function patchWorkflowModelInputIfPresent(path) {
|
|
212
|
+
try {
|
|
213
|
+
const existing = await readFile(path, "utf-8");
|
|
214
|
+
const modelInputPattern = /^\s*model:\s*".*"\s*\r?\n/m;
|
|
215
|
+
if (!modelInputPattern.test(existing)) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
const patched = existing.replace(modelInputPattern, "");
|
|
219
|
+
if (patched === existing) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
await writeFile(path, patched, "utf-8");
|
|
223
|
+
return true;
|
|
224
|
+
} catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function patchRunsRouteDeploymentCompletionIfNeeded(path) {
|
|
230
|
+
try {
|
|
231
|
+
const existing = await readFile(path, "utf-8");
|
|
232
|
+
if (existing.includes("merge_commit_sha")) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const oldBlockPattern =
|
|
237
|
+
/if \(pr\?\.merged_at\) \{\r?\n\s*const mergedAgo = Date\.now\(\) - new Date\(pr\.merged_at\)\.getTime\(\);\r?\n\s*if \(mergedAgo > 300000\) continue; \/\/ >5 min ago, done\r?\n\s*runs\.push\(\{ \.\.\.base, step: "deploying" \}\);\r?\n\s*continue;\r?\n\s*\}/;
|
|
238
|
+
|
|
239
|
+
if (!oldBlockPattern.test(existing)) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const replacementBlock = `if (pr?.merged_at) {
|
|
244
|
+
const mergedAtMs = new Date(pr.merged_at).getTime();
|
|
245
|
+
if (!Number.isFinite(mergedAtMs)) continue;
|
|
246
|
+
|
|
247
|
+
// Detect deploy completion using the merge commit deployment state.
|
|
248
|
+
const mergeSha = pr.merge_commit_sha;
|
|
249
|
+
if (mergeSha) {
|
|
250
|
+
try {
|
|
251
|
+
const depRes = await gh(
|
|
252
|
+
\`https://api.github.com/repos/\${repo}/deployments?sha=\${mergeSha}&per_page=1\`
|
|
253
|
+
);
|
|
254
|
+
if (depRes.ok) {
|
|
255
|
+
const deployments = await depRes.json();
|
|
256
|
+
if (deployments.length > 0) {
|
|
257
|
+
const depId = deployments[0]?.id;
|
|
258
|
+
if (depId) {
|
|
259
|
+
const depStatusRes = await gh(
|
|
260
|
+
\`https://api.github.com/repos/\${repo}/deployments/\${depId}/statuses?per_page=1\`
|
|
261
|
+
);
|
|
262
|
+
if (depStatusRes.ok) {
|
|
263
|
+
const depStatuses = await depStatusRes.json();
|
|
264
|
+
const depState = depStatuses?.[0]?.state;
|
|
265
|
+
if (depState === "success") continue;
|
|
266
|
+
if (depState === "failure" || depState === "error" || depState === "inactive") {
|
|
267
|
+
runs.push({ ...base, step: "error", failedStep: "Deployment failed" });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// Fall through to heuristic below.
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Fallback: keep a short deploying window if deployment status is unavailable.
|
|
280
|
+
const mergedAgo = Date.now() - mergedAtMs;
|
|
281
|
+
if (mergedAgo > 120000) continue; // >2 min ago, assume done
|
|
282
|
+
runs.push({ ...base, step: "deploying" });
|
|
283
|
+
continue;
|
|
284
|
+
}`;
|
|
285
|
+
|
|
286
|
+
const patched = existing.replace(oldBlockPattern, replacementBlock);
|
|
287
|
+
if (patched === existing) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
await writeFile(path, patched, "utf-8");
|
|
291
|
+
return true;
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Generate anteater.config.ts
|
|
299
|
+
*/
|
|
300
|
+
export function generateConfig({ repo, allowedGlobs, blockedGlobs, autoMerge, isTypeScript, productionBranch }) {
|
|
301
|
+
const ext = isTypeScript ? "ts" : "js";
|
|
302
|
+
const typeImport = isTypeScript
|
|
303
|
+
? `import type { AnteaterConfig } from "next-anteater";\n\n`
|
|
304
|
+
: "";
|
|
305
|
+
const typeAnnotation = isTypeScript ? ": AnteaterConfig" : "";
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
filename: `anteater.config.${ext}`,
|
|
309
|
+
content: `/**
|
|
310
|
+
* SECURITY: Anteater lets users modify your app's code via AI.
|
|
311
|
+
* Only expose the prompt bar to trusted users behind your own auth layer.
|
|
312
|
+
* Users can make destructive changes and potentially access sensitive data.
|
|
313
|
+
* Never use this in a production environment with real credentials.
|
|
314
|
+
* See: https://github.com/scottgriffinm/anteater#security-warning
|
|
315
|
+
*/
|
|
316
|
+
${typeImport}const config${typeAnnotation} = {
|
|
317
|
+
repo: "${repo}",
|
|
318
|
+
productionBranch: "${productionBranch}",
|
|
319
|
+
modes: ["prod", "copy"],
|
|
320
|
+
autoMerge: ${autoMerge},
|
|
321
|
+
|
|
322
|
+
allowedGlobs: [
|
|
323
|
+
${allowedGlobs.map((g) => ` "${g}",`).join("\n")}
|
|
324
|
+
],
|
|
325
|
+
|
|
326
|
+
blockedGlobs: [
|
|
327
|
+
${blockedGlobs.map((g) => ` "${g}",`).join("\n")}
|
|
328
|
+
],
|
|
329
|
+
|
|
330
|
+
requireReviewFor: ["auth", "billing", "payments", "dependencies"],
|
|
331
|
+
maxFilesChanged: 20,
|
|
332
|
+
maxDiffBytes: 120000,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export default config;
|
|
336
|
+
`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Generate API route handler.
|
|
342
|
+
*
|
|
343
|
+
* Uses string concatenation instead of nested template literals to avoid
|
|
344
|
+
* escape-sequence hell (template literals inside template literals).
|
|
345
|
+
*/
|
|
346
|
+
export function generateApiRoute({ isTypeScript, productionBranch }) {
|
|
347
|
+
const ext = isTypeScript ? "ts" : "js";
|
|
348
|
+
const TS = isTypeScript; // shorthand
|
|
349
|
+
const lines = [];
|
|
350
|
+
const add = (s) => lines.push(s);
|
|
351
|
+
|
|
352
|
+
// --- Imports ---
|
|
353
|
+
add('import { NextRequest, NextResponse } from "next/server";');
|
|
354
|
+
if (TS) add('import type { AnteaterRequest, AnteaterResponse, AnteaterStatusResponse } from "next-anteater";');
|
|
355
|
+
add("");
|
|
356
|
+
|
|
357
|
+
// --- Helpers ---
|
|
358
|
+
add("/** Auto-detect repo from Vercel system env vars, fall back to ANTEATER_GITHUB_REPO */");
|
|
359
|
+
add("function getRepo()" + (TS ? ": string | undefined" : "") + " {");
|
|
360
|
+
add(" if (process.env.ANTEATER_GITHUB_REPO) return process.env.ANTEATER_GITHUB_REPO;");
|
|
361
|
+
add(" const owner = process.env.VERCEL_GIT_REPO_OWNER;");
|
|
362
|
+
add(" const slug = process.env.VERCEL_GIT_REPO_SLUG;");
|
|
363
|
+
add(" if (owner && slug) return `${owner}/${slug}`;");
|
|
364
|
+
add(" return undefined;");
|
|
365
|
+
add("}");
|
|
366
|
+
add("");
|
|
367
|
+
add("function ghFetch(url" + (TS ? ": string" : "") + ") {");
|
|
368
|
+
add(" const token = process.env.GITHUB_TOKEN;");
|
|
369
|
+
add(" return fetch(url, {");
|
|
370
|
+
add(" headers: {");
|
|
371
|
+
add(" Authorization: `Bearer ${token}`,");
|
|
372
|
+
add(' Accept: "application/vnd.github+json",');
|
|
373
|
+
add(' "X-GitHub-Api-Version": "2022-11-28",');
|
|
374
|
+
add(" },");
|
|
375
|
+
add(' cache: "no-store",');
|
|
376
|
+
add(" });");
|
|
377
|
+
add("}");
|
|
378
|
+
add("");
|
|
379
|
+
add("/** Return status response with deployment ID for client-side deploy detection */");
|
|
380
|
+
add("function status(body" + (TS ? ": AnteaterStatusResponse" : "") + ", httpStatus" + (TS ? "?: number" : "") + ") {");
|
|
381
|
+
add(" const deploymentId = process.env.VERCEL_DEPLOYMENT_ID;");
|
|
382
|
+
add(" return NextResponse.json({ ...body, deploymentId }, httpStatus ? { status: httpStatus } : undefined);");
|
|
383
|
+
add("}");
|
|
384
|
+
add("");
|
|
385
|
+
|
|
386
|
+
// --- POST handler ---
|
|
387
|
+
add("export async function POST(request" + (TS ? ": NextRequest" : "") + ") {");
|
|
388
|
+
add(" try {");
|
|
389
|
+
add(' const contentType = request.headers.get("content-type") || "";');
|
|
390
|
+
add(' if (!contentType.toLowerCase().includes("application/json")) {');
|
|
391
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "(");
|
|
392
|
+
add(' { requestId: "", branch: "", status: "error", error: "Content-Type must be application/json" },');
|
|
393
|
+
add(" { status: 415 }");
|
|
394
|
+
add(" );");
|
|
395
|
+
add(" }");
|
|
396
|
+
add("");
|
|
397
|
+
add(" const body" + (TS ? ": AnteaterRequest" : "") + " = await request.json();");
|
|
398
|
+
add("");
|
|
399
|
+
add(" if (!body.prompt?.trim()) {");
|
|
400
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "(");
|
|
401
|
+
add(' { requestId: "", branch: "", status: "error", error: "Prompt is required" },');
|
|
402
|
+
add(" { status: 400 }");
|
|
403
|
+
add(" );");
|
|
404
|
+
add(" }");
|
|
405
|
+
add("");
|
|
406
|
+
add(" // Same-origin guard for mutating Anteater requests (no app auth integration required)");
|
|
407
|
+
add(" const requestOrigin = request.nextUrl.origin;");
|
|
408
|
+
add(' const fetchSite = request.headers.get("sec-fetch-site");');
|
|
409
|
+
add(' const origin = request.headers.get("origin");');
|
|
410
|
+
add(' const referer = request.headers.get("referer");');
|
|
411
|
+
add(" const hasMatchingOrigin = origin === requestOrigin;");
|
|
412
|
+
add(" const hasMatchingReferer = (() => {");
|
|
413
|
+
add(" if (!referer) return false;");
|
|
414
|
+
add(" try {");
|
|
415
|
+
add(" return new URL(referer).origin === requestOrigin;");
|
|
416
|
+
add(" } catch {");
|
|
417
|
+
add(" return false;");
|
|
418
|
+
add(" }");
|
|
419
|
+
add(" })();");
|
|
420
|
+
add(' const hasSameOriginBrowserSignal = fetchSite === "same-origin";');
|
|
421
|
+
add(" const hasValidSameOriginSignal = hasSameOriginBrowserSignal || hasMatchingOrigin || hasMatchingReferer;");
|
|
422
|
+
add(" const secret = process.env.ANTEATER_SECRET;");
|
|
423
|
+
add(' const hasValidSecret = !!secret && request.headers.get("x-anteater-secret") === secret;');
|
|
424
|
+
add(" if (!hasValidSameOriginSignal && !hasValidSecret) {");
|
|
425
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "(");
|
|
426
|
+
add(' { requestId: "", branch: "", status: "error", error: "Forbidden" },');
|
|
427
|
+
add(" { status: 403 }");
|
|
428
|
+
add(" );");
|
|
429
|
+
add(" }");
|
|
430
|
+
add("");
|
|
431
|
+
add(" const repo = getRepo();");
|
|
432
|
+
add(" const token = process.env.GITHUB_TOKEN;");
|
|
433
|
+
add(" if (!repo || !token) {");
|
|
434
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "(");
|
|
435
|
+
add(' { requestId: "", branch: "", status: "error", error: "Server misconfigured" },');
|
|
436
|
+
add(" { status: 500 }");
|
|
437
|
+
add(" );");
|
|
438
|
+
add(" }");
|
|
439
|
+
add("");
|
|
440
|
+
add(" const requestId = crypto.randomUUID().slice(0, 8);");
|
|
441
|
+
add(" const branch = body.mode === \"copy\"");
|
|
442
|
+
add(" ? `anteater/friend-${requestId}`");
|
|
443
|
+
add(" : `anteater/run-${requestId}`;");
|
|
444
|
+
add("");
|
|
445
|
+
add(" const dispatchRes = await fetch(");
|
|
446
|
+
add(" `https://api.github.com/repos/${repo}/actions/workflows/anteater.yml/dispatches`,");
|
|
447
|
+
add(" {");
|
|
448
|
+
add(' method: "POST",');
|
|
449
|
+
add(" headers: {");
|
|
450
|
+
add(" Authorization: `Bearer ${token}`,");
|
|
451
|
+
add(' Accept: "application/vnd.github+json",');
|
|
452
|
+
add(' "X-GitHub-Api-Version": "2022-11-28",');
|
|
453
|
+
add(" },");
|
|
454
|
+
add(" body: JSON.stringify({");
|
|
455
|
+
add(' ref: "' + productionBranch + '",');
|
|
456
|
+
add(" inputs: {");
|
|
457
|
+
add(" requestId,");
|
|
458
|
+
add(" prompt: body.prompt,");
|
|
459
|
+
add(' mode: body.mode || "prod",');
|
|
460
|
+
add(" branch,");
|
|
461
|
+
add(' baseBranch: "' + productionBranch + '",');
|
|
462
|
+
add(' autoMerge: String(body.mode !== "copy"),');
|
|
463
|
+
add(" },");
|
|
464
|
+
add(" }),");
|
|
465
|
+
add(" }");
|
|
466
|
+
add(" );");
|
|
467
|
+
add("");
|
|
468
|
+
add(" if (!dispatchRes.ok) {");
|
|
469
|
+
add(" const err = await dispatchRes.text();");
|
|
470
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "(");
|
|
471
|
+
add(" { requestId, branch, status: \"error\", error: `GitHub dispatch failed: ${dispatchRes.status}` },");
|
|
472
|
+
add(" { status: 502 }");
|
|
473
|
+
add(" );");
|
|
474
|
+
add(" }");
|
|
475
|
+
add("");
|
|
476
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "({ requestId, branch, status: \"queued\" });");
|
|
477
|
+
add(" } catch {");
|
|
478
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "(");
|
|
479
|
+
add(' { requestId: "", branch: "", status: "error", error: "Invalid request body" },');
|
|
480
|
+
add(" { status: 400 }");
|
|
481
|
+
add(" );");
|
|
482
|
+
add(" }");
|
|
483
|
+
add("}");
|
|
484
|
+
add("");
|
|
485
|
+
|
|
486
|
+
// --- GET handler (status polling) ---
|
|
487
|
+
add("/**");
|
|
488
|
+
add(" * GET /api/anteater?branch=anteater/run-xxx");
|
|
489
|
+
add(" * Polls pipeline status. Deploy detection handled client-side via deployment ID.");
|
|
490
|
+
add(" */");
|
|
491
|
+
add("export async function GET(request" + (TS ? ": NextRequest" : "") + ") {");
|
|
492
|
+
add(" const branch = request.nextUrl.searchParams.get(\"branch\");");
|
|
493
|
+
add(" if (!branch) {");
|
|
494
|
+
add(' return status({ step: "error", completed: true, error: "Missing branch param" }, 400);');
|
|
495
|
+
add(" }");
|
|
496
|
+
add("");
|
|
497
|
+
add(" const repo = getRepo();");
|
|
498
|
+
add(" const token = process.env.GITHUB_TOKEN;");
|
|
499
|
+
add(" if (!repo || !token) {");
|
|
500
|
+
add(' return status({ step: "error", completed: true, error: "Server misconfigured" }, 500);');
|
|
501
|
+
add(" }");
|
|
502
|
+
add("");
|
|
503
|
+
add(" try {");
|
|
504
|
+
add(" const prRes = await ghFetch(");
|
|
505
|
+
add(" `https://api.github.com/repos/${repo}/pulls?head=${repo.split(\"/\")[0]}:${branch}&state=all&per_page=1`,");
|
|
506
|
+
add(" );");
|
|
507
|
+
add(" if (prRes.ok) {");
|
|
508
|
+
add(" const prs = await prRes.json();");
|
|
509
|
+
add(" if (prs.length) {");
|
|
510
|
+
add(" const pr = prs[0];");
|
|
511
|
+
add(" if (pr.merged_at) {");
|
|
512
|
+
add(" return status({ step: \"deploying\", completed: false });");
|
|
513
|
+
add(" }");
|
|
514
|
+
add(" if (pr.state === \"closed\") {");
|
|
515
|
+
add(' return status({ step: "error", completed: true, error: "PR was closed without merging" });');
|
|
516
|
+
add(" }");
|
|
517
|
+
add(" return status({ step: \"merging\", completed: false });");
|
|
518
|
+
add(" }");
|
|
519
|
+
add(" }");
|
|
520
|
+
add("");
|
|
521
|
+
add(" const branchRes = await ghFetch(");
|
|
522
|
+
add(" `https://api.github.com/repos/${repo}/git/refs/heads/${branch}`,");
|
|
523
|
+
add(" );");
|
|
524
|
+
add(" if (branchRes.ok) {");
|
|
525
|
+
add(" return status({ step: \"merging\", completed: false });");
|
|
526
|
+
add(" }");
|
|
527
|
+
add("");
|
|
528
|
+
add(" const runsRes = await ghFetch(");
|
|
529
|
+
add(" `https://api.github.com/repos/${repo}/actions/workflows/anteater.yml/runs?per_page=5`,");
|
|
530
|
+
add(" );");
|
|
531
|
+
add(" if (runsRes.ok) {");
|
|
532
|
+
add(" const { workflow_runs: runs } = await runsRes.json();");
|
|
533
|
+
add(" const recentFailed = runs?.find(");
|
|
534
|
+
add(' (r' + (TS ? ": { status: string; conclusion: string; created_at: string }" : "") + ') => r.status === "completed" && r.conclusion === "failure" &&');
|
|
535
|
+
add(" Date.now() - new Date(r.created_at).getTime() < 5 * 60 * 1000,");
|
|
536
|
+
add(" );");
|
|
537
|
+
add(" if (recentFailed) {");
|
|
538
|
+
add(' return status({ step: "error", completed: true, error: "Workflow failed — check GitHub Actions" });');
|
|
539
|
+
add(" }");
|
|
540
|
+
add(" }");
|
|
541
|
+
add("");
|
|
542
|
+
add(" return status({ step: \"working\", completed: false });");
|
|
543
|
+
add(" } catch {");
|
|
544
|
+
add(' return status({ step: "error", completed: true, error: "Status check failed" }, 500);');
|
|
545
|
+
add(" }");
|
|
546
|
+
add("}");
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
filename: `route.${ext}`,
|
|
550
|
+
content: lines.join("\n") + "\n",
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Generate .claude/settings.local.json for agent permissions.
|
|
556
|
+
*/
|
|
557
|
+
export function generateClaudeSettings({ model, permissionsMode }) {
|
|
558
|
+
if (permissionsMode === "unrestricted") {
|
|
559
|
+
return JSON.stringify({
|
|
560
|
+
model,
|
|
561
|
+
alwaysThinkingEnabled: true,
|
|
562
|
+
skipDangerousModePermissionPrompt: true,
|
|
563
|
+
permissions: {
|
|
564
|
+
defaultMode: "bypassPermissions",
|
|
565
|
+
allow: [
|
|
566
|
+
"Bash", "Edit", "Write", "MultiEdit", "NotebookEdit",
|
|
567
|
+
"WebFetch", "WebSearch", "Skill", "mcp__*",
|
|
568
|
+
],
|
|
569
|
+
deny: [],
|
|
570
|
+
},
|
|
571
|
+
}, null, 2) + "\n";
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Sandboxed (default)
|
|
575
|
+
return JSON.stringify({
|
|
576
|
+
model,
|
|
577
|
+
alwaysThinkingEnabled: true,
|
|
578
|
+
skipDangerousModePermissionPrompt: true,
|
|
579
|
+
permissions: {
|
|
580
|
+
defaultMode: "bypassPermissions",
|
|
581
|
+
allow: [
|
|
582
|
+
"Read", "Edit", "Write", "Glob", "Grep",
|
|
583
|
+
"Bash(git *)", "Bash(npm *)", "Bash(pnpm *)",
|
|
584
|
+
"Bash(npx *)", "Bash(node *)", "Bash(ls *)",
|
|
585
|
+
"Bash(find *)", "Bash(mkdir *)", "Bash(rm *)",
|
|
586
|
+
"Bash(cp *)", "Bash(mv *)",
|
|
587
|
+
],
|
|
588
|
+
deny: [
|
|
589
|
+
"WebFetch", "WebSearch",
|
|
590
|
+
"Bash(curl *)", "Bash(wget *)",
|
|
591
|
+
"Bash(gh *)", "Bash(vercel *)",
|
|
592
|
+
"mcp__*",
|
|
593
|
+
],
|
|
594
|
+
},
|
|
595
|
+
}, null, 2) + "\n";
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Generate the GitHub Actions workflow.
|
|
600
|
+
*/
|
|
601
|
+
export function generateWorkflow({ allowedGlobs, blockedGlobs, productionBranch, model, packageManager = "npm" }) {
|
|
602
|
+
const allowed = allowedGlobs.join(", ");
|
|
603
|
+
const blocked = blockedGlobs.join(", ");
|
|
604
|
+
|
|
605
|
+
return `name: Anteater Apply
|
|
606
|
+
run-name: "anteater [\${{ inputs.requestId }}] [\${{ inputs.mode }}] \${{ inputs.prompt }}"
|
|
607
|
+
|
|
608
|
+
on:
|
|
609
|
+
workflow_dispatch:
|
|
610
|
+
inputs:
|
|
611
|
+
requestId:
|
|
612
|
+
description: "Unique request ID"
|
|
613
|
+
required: true
|
|
614
|
+
prompt:
|
|
615
|
+
description: "Natural language change request"
|
|
616
|
+
required: true
|
|
617
|
+
mode:
|
|
618
|
+
description: "prod or copy"
|
|
619
|
+
required: true
|
|
620
|
+
default: "prod"
|
|
621
|
+
branch:
|
|
622
|
+
description: "Branch to create and commit to"
|
|
623
|
+
required: true
|
|
624
|
+
baseBranch:
|
|
625
|
+
description: "Base branch to fork from"
|
|
626
|
+
required: true
|
|
627
|
+
default: "${productionBranch}"
|
|
628
|
+
autoMerge:
|
|
629
|
+
description: "Auto-merge the PR if true"
|
|
630
|
+
required: false
|
|
631
|
+
default: "true"
|
|
632
|
+
|
|
633
|
+
permissions:
|
|
634
|
+
contents: write
|
|
635
|
+
pull-requests: write
|
|
636
|
+
id-token: write
|
|
637
|
+
|
|
638
|
+
jobs:
|
|
639
|
+
apply:
|
|
640
|
+
runs-on: ubuntu-latest
|
|
641
|
+
timeout-minutes: 360
|
|
642
|
+
steps:
|
|
643
|
+
- name: Checkout base branch
|
|
644
|
+
uses: actions/checkout@v4
|
|
645
|
+
with:
|
|
646
|
+
ref: \${{ inputs.baseBranch }}
|
|
647
|
+
fetch-depth: 0
|
|
648
|
+
|
|
649
|
+
- name: Create and switch to target branch
|
|
650
|
+
run: git checkout -b "\${{ inputs.branch }}"
|
|
651
|
+
|
|
652
|
+
- name: Setup Node.js
|
|
653
|
+
uses: actions/setup-node@v4
|
|
654
|
+
with:
|
|
655
|
+
node-version: 22
|
|
656
|
+
|
|
657
|
+
- name: Install dependencies
|
|
658
|
+
run: ${packageManager === "pnpm" ? "npm install -g pnpm@9 --silent && pnpm install --frozen-lockfile" : packageManager === "yarn" ? "yarn install --frozen-lockfile" : "npm ci"}
|
|
659
|
+
|
|
660
|
+
- name: Run Anteater agent
|
|
661
|
+
uses: anthropics/claude-code-action@v1
|
|
662
|
+
with:
|
|
663
|
+
prompt: |
|
|
664
|
+
You are Anteater, an AI agent that modifies a web app based on user requests.
|
|
665
|
+
|
|
666
|
+
USER REQUEST: \${{ inputs.prompt }}
|
|
667
|
+
|
|
668
|
+
RULES:
|
|
669
|
+
- Only edit files under: ${allowed}
|
|
670
|
+
- NEVER edit: ${blocked}
|
|
671
|
+
- Make minimal, focused changes
|
|
672
|
+
- Preserve existing code style
|
|
673
|
+
- After making changes, run the build command to verify the build passes
|
|
674
|
+
- If the build fails, read the error output and fix the issues, then build again
|
|
675
|
+
- Keep iterating until the build passes or you've tried 3 times
|
|
676
|
+
- Do NOT commit — just leave the changed files on disk
|
|
677
|
+
|
|
678
|
+
IMPORTANT: Always verify your changes compile by running the build command.
|
|
679
|
+
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
|
680
|
+
claude_args: "--allowedTools Edit,Read,Write,Bash,Glob,Grep --max-turns 25"
|
|
681
|
+
|
|
682
|
+
- name: Check for changes
|
|
683
|
+
id: changes
|
|
684
|
+
run: |
|
|
685
|
+
git add -A
|
|
686
|
+
if git diff --staged --quiet; then
|
|
687
|
+
echo "has_changes=false" >> "\$GITHUB_OUTPUT"
|
|
688
|
+
else
|
|
689
|
+
echo "has_changes=true" >> "\$GITHUB_OUTPUT"
|
|
690
|
+
fi
|
|
691
|
+
|
|
692
|
+
- name: Commit changes
|
|
693
|
+
if: steps.changes.outputs.has_changes == 'true'
|
|
694
|
+
env:
|
|
695
|
+
PROMPT: \${{ inputs.prompt }}
|
|
696
|
+
run: |
|
|
697
|
+
git config user.name "anteater[bot]"
|
|
698
|
+
git config user.email "anteater[bot]@users.noreply.github.com"
|
|
699
|
+
git commit -m "anteater: \${PROMPT}"
|
|
700
|
+
|
|
701
|
+
- name: Push branch
|
|
702
|
+
if: steps.changes.outputs.has_changes == 'true'
|
|
703
|
+
run: |
|
|
704
|
+
git remote set-url origin "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git"
|
|
705
|
+
git push origin "\${{ inputs.branch }}"
|
|
706
|
+
env:
|
|
707
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
708
|
+
|
|
709
|
+
- name: Create pull request
|
|
710
|
+
if: steps.changes.outputs.has_changes == 'true'
|
|
711
|
+
env:
|
|
712
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
713
|
+
PROMPT: \${{ inputs.prompt }}
|
|
714
|
+
REQUEST_ID: \${{ inputs.requestId }}
|
|
715
|
+
MODE: \${{ inputs.mode }}
|
|
716
|
+
run: |
|
|
717
|
+
gh pr create \\
|
|
718
|
+
--base "\${{ inputs.baseBranch }}" \\
|
|
719
|
+
--head "\${{ inputs.branch }}" \\
|
|
720
|
+
--title "anteater: \${PROMPT}" \\
|
|
721
|
+
--body "Automated change by Anteater (request \\\`\${REQUEST_ID}\\\`).
|
|
722
|
+
|
|
723
|
+
**Prompt:** \${PROMPT}
|
|
724
|
+
**Mode:** \${MODE}"
|
|
725
|
+
|
|
726
|
+
- name: Auto-merge PR
|
|
727
|
+
if: steps.changes.outputs.has_changes == 'true' && inputs.autoMerge == 'true'
|
|
728
|
+
env:
|
|
729
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
730
|
+
run: gh pr merge "\${{ inputs.branch }}" --squash --delete-branch
|
|
731
|
+
`;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Generate the /api/anteater/runs route handler for multi-run discovery.
|
|
736
|
+
*
|
|
737
|
+
* Uses workflow runs as the primary data source (via run-name containing
|
|
738
|
+
* requestId, mode, and prompt). Fetches job steps only for in-progress runs
|
|
739
|
+
* (to distinguish initializing vs working) and failed runs (to get the
|
|
740
|
+
* failing step name). Merges with PR data for post-merge states.
|
|
741
|
+
*/
|
|
742
|
+
export function generateRunsRoute({ isTypeScript }) {
|
|
743
|
+
const ext = isTypeScript ? "ts" : "js";
|
|
744
|
+
const TS = isTypeScript;
|
|
745
|
+
const lines = [];
|
|
746
|
+
const add = (s) => lines.push(s);
|
|
747
|
+
|
|
748
|
+
add('import { NextResponse } from "next/server";');
|
|
749
|
+
if (TS) add('import type { AnteaterRun, AnteaterRunsResponse } from "next-anteater";');
|
|
750
|
+
add("");
|
|
751
|
+
|
|
752
|
+
// --- Helpers ---
|
|
753
|
+
add("function getRepo()" + (TS ? ": string | undefined" : "") + " {");
|
|
754
|
+
add(" if (process.env.ANTEATER_GITHUB_REPO) return process.env.ANTEATER_GITHUB_REPO;");
|
|
755
|
+
add(" const owner = process.env.VERCEL_GIT_REPO_OWNER;");
|
|
756
|
+
add(" const slug = process.env.VERCEL_GIT_REPO_SLUG;");
|
|
757
|
+
add(" if (owner && slug) return `${owner}/${slug}`;");
|
|
758
|
+
add(" return undefined;");
|
|
759
|
+
add("}");
|
|
760
|
+
add("");
|
|
761
|
+
add("function emptyResponse() {");
|
|
762
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterRunsResponse>" : "") + "({ runs: [], deploymentId: process.env.VERCEL_DEPLOYMENT_ID });");
|
|
763
|
+
add("}");
|
|
764
|
+
add("");
|
|
765
|
+
add("/** Parse run-name format: \"anteater [requestId] [mode] prompt text\" */");
|
|
766
|
+
add("function parseRunName(title" + (TS ? ": string" : "") + ")" + (TS ? ": { requestId: string; mode: \"prod\" | \"copy\"; prompt: string } | null" : "") + " {");
|
|
767
|
+
add(" const m = title.match(/^anteater \\[([^\\]]+)\\] \\[([^\\]]+)\\] (.+)$/);");
|
|
768
|
+
add(" if (!m) return null;");
|
|
769
|
+
add(" return { requestId: m[1], mode: m[2] === \"copy\" ? \"copy\" : \"prod\"" + (TS ? " as const" : "") + ", prompt: m[3] };");
|
|
770
|
+
add("}");
|
|
771
|
+
add("");
|
|
772
|
+
|
|
773
|
+
// --- GET handler ---
|
|
774
|
+
add("export async function GET() {");
|
|
775
|
+
add(" const repo = getRepo();");
|
|
776
|
+
add(" const token = process.env.GITHUB_TOKEN;");
|
|
777
|
+
add(" if (!repo || !token) return emptyResponse();");
|
|
778
|
+
add("");
|
|
779
|
+
add(" const gh = (url" + (TS ? ": string" : "") + ") =>");
|
|
780
|
+
add(" fetch(url, {");
|
|
781
|
+
add(" headers: {");
|
|
782
|
+
add(" Authorization: `Bearer ${token}`,");
|
|
783
|
+
add(' Accept: "application/vnd.github+json",');
|
|
784
|
+
add(' "X-GitHub-Api-Version": "2022-11-28",');
|
|
785
|
+
add(" },");
|
|
786
|
+
add(' cache: "no-store",');
|
|
787
|
+
add(" });");
|
|
788
|
+
add("");
|
|
789
|
+
add(" try {");
|
|
790
|
+
add(" // Fetch workflow runs and PRs in parallel");
|
|
791
|
+
add(" const [wfRes, prsRes] = await Promise.all([");
|
|
792
|
+
add(" gh(`https://api.github.com/repos/${repo}/actions/workflows/anteater.yml/runs?per_page=10`),");
|
|
793
|
+
add(" gh(`https://api.github.com/repos/${repo}/pulls?state=all&per_page=20&sort=created&direction=desc`),");
|
|
794
|
+
add(" ]);");
|
|
795
|
+
add("");
|
|
796
|
+
add(" const wfData = wfRes.ok ? await wfRes.json() : { workflow_runs: [] };");
|
|
797
|
+
add(" const allPrs" + (TS ? ": any[]" : "") + " = prsRes.ok ? await prsRes.json() : [];");
|
|
798
|
+
add("");
|
|
799
|
+
add(" // Filter workflow runs that have our run-name format");
|
|
800
|
+
add(" const wfRuns = (wfData.workflow_runs || []).filter(");
|
|
801
|
+
add(" (r" + (TS ? ": any" : "") + ") => r.display_title?.startsWith(\"anteater [\")");
|
|
802
|
+
add(" );");
|
|
803
|
+
add("");
|
|
804
|
+
add(" // Index PRs by requestId (last segment of branch name)");
|
|
805
|
+
add(" const anteaterPrs = allPrs.filter((pr" + (TS ? ": any" : "") + ") => pr.head.ref.startsWith(\"anteater/\"));");
|
|
806
|
+
add(" const prByReqId = new Map" + (TS ? "<string, any>" : "") + "();");
|
|
807
|
+
add(" for (const pr of anteaterPrs) {");
|
|
808
|
+
add(" const parts = pr.head.ref.split(\"-\");");
|
|
809
|
+
add(" const reqId = parts[parts.length - 1];");
|
|
810
|
+
add(" if (reqId) prByReqId.set(reqId, pr);");
|
|
811
|
+
add(" }");
|
|
812
|
+
add("");
|
|
813
|
+
add(" // First pass: classify each workflow run, collect jobs-needed list");
|
|
814
|
+
add(" const runs" + (TS ? ": AnteaterRun[]" : "") + " = [];");
|
|
815
|
+
add(" const needJobs" + (TS ? ": Array<{ wfRun: any; runData: Omit<AnteaterRun, \"step\"> }>" : "") + " = [];");
|
|
816
|
+
add("");
|
|
817
|
+
add(" for (const wfRun of wfRuns) {");
|
|
818
|
+
add(" const parsed = parseRunName(wfRun.display_title);");
|
|
819
|
+
add(" if (!parsed) continue;");
|
|
820
|
+
add("");
|
|
821
|
+
add(" const { requestId, mode, prompt } = parsed;");
|
|
822
|
+
add(" const branch = mode === \"copy\"");
|
|
823
|
+
add(" ? `anteater/friend-${requestId}`");
|
|
824
|
+
add(" : `anteater/run-${requestId}`;");
|
|
825
|
+
add(" const startedAt = wfRun.created_at;");
|
|
826
|
+
add(" const pr = prByReqId.get(requestId);");
|
|
827
|
+
add(" const base = { branch, requestId, prompt, mode, startedAt };");
|
|
828
|
+
add("");
|
|
829
|
+
add(" // Check PR state first (takes precedence for later stages)");
|
|
830
|
+
add(" if (pr?.merged_at) {");
|
|
831
|
+
add(" const mergedAtMs = new Date(pr.merged_at).getTime();");
|
|
832
|
+
add(" if (!Number.isFinite(mergedAtMs)) continue;");
|
|
833
|
+
add("");
|
|
834
|
+
add(" // Detect deploy completion using the merge commit deployment state.");
|
|
835
|
+
add(" const mergeSha = pr.merge_commit_sha;");
|
|
836
|
+
add(" if (mergeSha) {");
|
|
837
|
+
add(" try {");
|
|
838
|
+
add(" const depRes = await gh(");
|
|
839
|
+
add(" `https://api.github.com/repos/${repo}/deployments?sha=${mergeSha}&per_page=1`");
|
|
840
|
+
add(" );");
|
|
841
|
+
add(" if (depRes.ok) {");
|
|
842
|
+
add(" const deployments = await depRes.json();");
|
|
843
|
+
add(" if (deployments.length > 0) {");
|
|
844
|
+
add(" const depId = deployments[0]?.id;");
|
|
845
|
+
add(" if (depId) {");
|
|
846
|
+
add(" const depStatusRes = await gh(");
|
|
847
|
+
add(" `https://api.github.com/repos/${repo}/deployments/${depId}/statuses?per_page=1`");
|
|
848
|
+
add(" );");
|
|
849
|
+
add(" if (depStatusRes.ok) {");
|
|
850
|
+
add(" const depStatuses = await depStatusRes.json();");
|
|
851
|
+
add(" const depState = depStatuses?.[0]?.state;");
|
|
852
|
+
add(" if (depState === \"success\") continue;");
|
|
853
|
+
add(" if (depState === \"failure\" || depState === \"error\" || depState === \"inactive\") {");
|
|
854
|
+
add(" runs.push({ ...base, step: \"error\", failedStep: \"Deployment failed\" });");
|
|
855
|
+
add(" continue;");
|
|
856
|
+
add(" }");
|
|
857
|
+
add(" }");
|
|
858
|
+
add(" }");
|
|
859
|
+
add(" }");
|
|
860
|
+
add(" }");
|
|
861
|
+
add(" } catch {");
|
|
862
|
+
add(" // Fall through to heuristic below.");
|
|
863
|
+
add(" }");
|
|
864
|
+
add(" }");
|
|
865
|
+
add("");
|
|
866
|
+
add(" // Fallback: keep a short deploying window if deployment status is unavailable.");
|
|
867
|
+
add(" const mergedAgo = Date.now() - mergedAtMs;");
|
|
868
|
+
add(" if (mergedAgo > 120000) continue; // >2 min ago, assume done");
|
|
869
|
+
add(" runs.push({ ...base, step: \"deploying\" });");
|
|
870
|
+
add(" continue;");
|
|
871
|
+
add(" }");
|
|
872
|
+
add(" if (pr?.state === \"closed\") continue; // closed without merge");
|
|
873
|
+
add(" if (pr?.state === \"open\") {");
|
|
874
|
+
add(" runs.push({ ...base, step: \"merging\" });");
|
|
875
|
+
add(" continue;");
|
|
876
|
+
add(" }");
|
|
877
|
+
add("");
|
|
878
|
+
add(" // No PR — determine step from workflow run status");
|
|
879
|
+
add(" if (wfRun.status === \"completed\") {");
|
|
880
|
+
add(" if (wfRun.conclusion === \"failure\") {");
|
|
881
|
+
add(" // Need jobs to find which step failed");
|
|
882
|
+
add(" needJobs.push({ wfRun, runData: base });");
|
|
883
|
+
add(" }");
|
|
884
|
+
add(" // success without PR shouldn't happen (no changes?), skip");
|
|
885
|
+
add(" continue;");
|
|
886
|
+
add(" }");
|
|
887
|
+
add("");
|
|
888
|
+
add(" if (wfRun.status === \"queued\") {");
|
|
889
|
+
add(" runs.push({ ...base, step: \"initializing\" });");
|
|
890
|
+
add(" continue;");
|
|
891
|
+
add(" }");
|
|
892
|
+
add("");
|
|
893
|
+
add(" if (wfRun.status === \"in_progress\") {");
|
|
894
|
+
add(" // Need jobs to check if agent step has started");
|
|
895
|
+
add(" needJobs.push({ wfRun, runData: base });");
|
|
896
|
+
add(" continue;");
|
|
897
|
+
add(" }");
|
|
898
|
+
add(" }");
|
|
899
|
+
add("");
|
|
900
|
+
add(" // Second pass: fetch jobs in parallel for runs that need step detail");
|
|
901
|
+
add(" if (needJobs.length > 0) {");
|
|
902
|
+
add(" const jobResults = await Promise.all(");
|
|
903
|
+
add(" needJobs.map(async ({ wfRun, runData }) => {");
|
|
904
|
+
add(" try {");
|
|
905
|
+
add(" const res = await gh(wfRun.jobs_url);");
|
|
906
|
+
add(" if (!res.ok) return { wfRun, runData, steps: [] };");
|
|
907
|
+
add(" const data = await res.json();");
|
|
908
|
+
add(" return { wfRun, runData, steps: data.jobs?.[0]?.steps || [] };");
|
|
909
|
+
add(" } catch {");
|
|
910
|
+
add(" return { wfRun, runData, steps: [] };");
|
|
911
|
+
add(" }");
|
|
912
|
+
add(" })");
|
|
913
|
+
add(" );");
|
|
914
|
+
add("");
|
|
915
|
+
add(" for (const { wfRun, runData, steps } of jobResults) {");
|
|
916
|
+
add(" if (wfRun.conclusion === \"failure\") {");
|
|
917
|
+
add(" const failed = steps.find((s" + (TS ? ": any" : "") + ") => s.conclusion === \"failure\");");
|
|
918
|
+
add(" runs.push({ ...runData, step: \"error\", failedStep: failed?.name || \"Unknown\" });");
|
|
919
|
+
add(" } else {");
|
|
920
|
+
add(" // in_progress — check if agent step has started");
|
|
921
|
+
add(" const agentStep = steps.find((s" + (TS ? ": any" : "") + ") => s.name === \"Run Anteater agent\");");
|
|
922
|
+
add(" const isWorking = agentStep?.status === \"in_progress\" || agentStep?.conclusion === \"success\";");
|
|
923
|
+
add(" runs.push({ ...runData, step: isWorking ? \"working\" : \"initializing\" });");
|
|
924
|
+
add(" }");
|
|
925
|
+
add(" }");
|
|
926
|
+
add(" }");
|
|
927
|
+
add("");
|
|
928
|
+
add(" // Sort newest first, cap at 5");
|
|
929
|
+
add(" // Drop stale failed runs (>1h) so old errors don't clutter the bar");
|
|
930
|
+
add(" const failedCutoffMs = 60 * 60 * 1000;");
|
|
931
|
+
add(" const freshRuns = runs.filter((r) => {");
|
|
932
|
+
add(" if (r.step !== \"error\") return true;");
|
|
933
|
+
add(" const startedAtMs = new Date(r.startedAt).getTime();");
|
|
934
|
+
add(" if (Number.isNaN(startedAtMs)) return true;");
|
|
935
|
+
add(" return Date.now() - startedAtMs <= failedCutoffMs;");
|
|
936
|
+
add(" });");
|
|
937
|
+
add("");
|
|
938
|
+
add(" freshRuns.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());");
|
|
939
|
+
add("");
|
|
940
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterRunsResponse>" : "") + "(");
|
|
941
|
+
add(" { runs: freshRuns.slice(0, 5), deploymentId: process.env.VERCEL_DEPLOYMENT_ID }");
|
|
942
|
+
add(" );");
|
|
943
|
+
add(" } catch {");
|
|
944
|
+
add(" return emptyResponse();");
|
|
945
|
+
add(" }");
|
|
946
|
+
add("}");
|
|
947
|
+
add("");
|
|
948
|
+
|
|
949
|
+
for (const line of buildRunsDeleteHandlerLines(TS)) add(line);
|
|
950
|
+
|
|
951
|
+
return {
|
|
952
|
+
filename: `route.${ext}`,
|
|
953
|
+
content: lines.join("\n") + "\n",
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function buildRunsDeleteHandlerLines(TS) {
|
|
958
|
+
const lines = [];
|
|
959
|
+
const add = (s) => lines.push(s);
|
|
960
|
+
|
|
961
|
+
add("");
|
|
962
|
+
add("// --- DELETE handler: delete a failed workflow run by requestId ---");
|
|
963
|
+
add("export async function DELETE(request" + (TS ? ": Request" : "") + ") {");
|
|
964
|
+
add(" const repo = getRepo();");
|
|
965
|
+
add(" const token = process.env.GITHUB_TOKEN;");
|
|
966
|
+
add(" if (!repo || !token) {");
|
|
967
|
+
add(" return NextResponse.json({ error: \"Server misconfigured\" }, { status: 500 });");
|
|
968
|
+
add(" }");
|
|
969
|
+
add("");
|
|
970
|
+
add(" const { searchParams } = new URL(request.url);");
|
|
971
|
+
add(" const requestId = searchParams.get(\"requestId\");");
|
|
972
|
+
add(" if (!requestId) {");
|
|
973
|
+
add(" return NextResponse.json({ error: \"requestId is required\" }, { status: 400 });");
|
|
974
|
+
add(" }");
|
|
975
|
+
add("");
|
|
976
|
+
add(" // Same-origin guard for mutating runs endpoint (no app auth integration required)");
|
|
977
|
+
add(" const requestOrigin = new URL(request.url).origin;");
|
|
978
|
+
add(' const fetchSite = request.headers.get("sec-fetch-site");');
|
|
979
|
+
add(' const origin = request.headers.get("origin");');
|
|
980
|
+
add(' const referer = request.headers.get("referer");');
|
|
981
|
+
add(" const hasMatchingOrigin = origin === requestOrigin;");
|
|
982
|
+
add(" const hasMatchingReferer = (() => {");
|
|
983
|
+
add(" if (!referer) return false;");
|
|
984
|
+
add(" try {");
|
|
985
|
+
add(" return new URL(referer).origin === requestOrigin;");
|
|
986
|
+
add(" } catch {");
|
|
987
|
+
add(" return false;");
|
|
988
|
+
add(" }");
|
|
989
|
+
add(" })();");
|
|
990
|
+
add(' const hasSameOriginBrowserSignal = fetchSite === "same-origin";');
|
|
991
|
+
add(" const hasValidSameOriginSignal = hasSameOriginBrowserSignal || hasMatchingOrigin || hasMatchingReferer;");
|
|
992
|
+
add(" const secret = process.env.ANTEATER_SECRET;");
|
|
993
|
+
add(' const hasValidSecret = !!secret && request.headers.get("x-anteater-secret") === secret;');
|
|
994
|
+
add(" if (!hasValidSameOriginSignal && !hasValidSecret) {");
|
|
995
|
+
add(' return NextResponse.json({ error: "Forbidden" }, { status: 403 });');
|
|
996
|
+
add(" }");
|
|
997
|
+
add("");
|
|
998
|
+
add(" const gh = (url" + (TS ? ": string" : "") + ", options" + (TS ? "?: RequestInit" : "") + ") =>");
|
|
999
|
+
add(" fetch(url, {");
|
|
1000
|
+
add(" ...options,");
|
|
1001
|
+
add(" headers: {");
|
|
1002
|
+
add(" Authorization: `Bearer ${token}`,");
|
|
1003
|
+
add(' Accept: "application/vnd.github+json",');
|
|
1004
|
+
add(' "X-GitHub-Api-Version": "2022-11-28",');
|
|
1005
|
+
add(" ...options?.headers,");
|
|
1006
|
+
add(" },");
|
|
1007
|
+
add(" });");
|
|
1008
|
+
add("");
|
|
1009
|
+
add(" try {");
|
|
1010
|
+
add(" // Find the workflow run matching this requestId");
|
|
1011
|
+
add(" const res = await gh(");
|
|
1012
|
+
add(" `https://api.github.com/repos/${repo}/actions/workflows/anteater.yml/runs?per_page=100`");
|
|
1013
|
+
add(" );");
|
|
1014
|
+
add(" if (!res.ok) {");
|
|
1015
|
+
add(" return NextResponse.json({ error: \"Failed to fetch workflow runs\" }, { status: 502 });");
|
|
1016
|
+
add(" }");
|
|
1017
|
+
add("");
|
|
1018
|
+
add(" const data = await res.json();");
|
|
1019
|
+
add(" const wfRun = (data.workflow_runs || []).find(");
|
|
1020
|
+
add(" (r" + (TS ? ": any" : "") + ") => r.display_title?.includes(`[${requestId}]`)");
|
|
1021
|
+
add(" );");
|
|
1022
|
+
add("");
|
|
1023
|
+
add(" if (!wfRun) {");
|
|
1024
|
+
add(" return NextResponse.json({ error: \"Workflow run not found\" }, { status: 404 });");
|
|
1025
|
+
add(" }");
|
|
1026
|
+
add("");
|
|
1027
|
+
add(" // Only failed runs are deletable from this endpoint");
|
|
1028
|
+
add(" if (!(wfRun.status === \"completed\" && wfRun.conclusion === \"failure\")) {");
|
|
1029
|
+
add(" return NextResponse.json({ error: \"Only failed runs can be deleted\" }, { status: 409 });");
|
|
1030
|
+
add(" }");
|
|
1031
|
+
add("");
|
|
1032
|
+
add(" // Delete the workflow run");
|
|
1033
|
+
add(" const delRes = await gh(");
|
|
1034
|
+
add(" `https://api.github.com/repos/${repo}/actions/runs/${wfRun.id}`,");
|
|
1035
|
+
add(" { method: \"DELETE\" }");
|
|
1036
|
+
add(" );");
|
|
1037
|
+
add("");
|
|
1038
|
+
add(" if (!delRes.ok && delRes.status !== 204) {");
|
|
1039
|
+
add(" return NextResponse.json({ error: \"Failed to delete workflow run\" }, { status: 502 });");
|
|
1040
|
+
add(" }");
|
|
1041
|
+
add("");
|
|
1042
|
+
add(" return NextResponse.json({ deleted: true });");
|
|
1043
|
+
add(" } catch {");
|
|
1044
|
+
add(" return NextResponse.json({ error: \"Delete failed\" }, { status: 500 });");
|
|
1045
|
+
add(" }");
|
|
1046
|
+
add("}");
|
|
1047
|
+
|
|
1048
|
+
return lines;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function buildRunsDeleteHandler(isTypeScript) {
|
|
1052
|
+
return buildRunsDeleteHandlerLines(isTypeScript).join("\n");
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Generate the AI apply script.
|
|
1057
|
+
*/
|
|
1058
|
+
export function generateApplyScript() {
|
|
1059
|
+
// Read from the existing script in the monorepo — or inline it
|
|
1060
|
+
return `#!/usr/bin/env node
|
|
1061
|
+
|
|
1062
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
1063
|
+
import { dirname, relative, resolve } from "node:path";
|
|
1064
|
+
import { glob } from "node:fs/promises";
|
|
1065
|
+
import { parseArgs } from "node:util";
|
|
1066
|
+
import { fileURLToPath } from "node:url";
|
|
1067
|
+
|
|
1068
|
+
const { values: args } = parseArgs({
|
|
1069
|
+
options: {
|
|
1070
|
+
prompt: { type: "string" },
|
|
1071
|
+
"allowed-paths": { type: "string" },
|
|
1072
|
+
"blocked-paths": { type: "string", default: "" },
|
|
1073
|
+
},
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
1077
|
+
if (!ANTHROPIC_API_KEY) { console.error("Missing ANTHROPIC_API_KEY"); process.exit(1); }
|
|
1078
|
+
if (!args.prompt) { console.error("Missing --prompt"); process.exit(1); }
|
|
1079
|
+
|
|
1080
|
+
const allowedGlobs = args["allowed-paths"]?.split(",").map((s) => s.trim()) ?? [];
|
|
1081
|
+
const blockedGlobs = args["blocked-paths"]?.split(",").filter(Boolean).map((s) => s.trim()) ?? [];
|
|
1082
|
+
|
|
1083
|
+
async function collectFiles() {
|
|
1084
|
+
const files = new Set();
|
|
1085
|
+
for (const pattern of allowedGlobs) {
|
|
1086
|
+
for await (const entry of glob(pattern)) {
|
|
1087
|
+
const rel = relative(process.cwd(), resolve(entry)).replace(/\\\\/g, "/");
|
|
1088
|
+
let blocked = false;
|
|
1089
|
+
for (const bp of blockedGlobs) {
|
|
1090
|
+
const prefix = bp.replace(/\\/?\\*\\*?$/, "");
|
|
1091
|
+
if (rel === prefix || rel.startsWith(prefix + "/")) { blocked = true; break; }
|
|
1092
|
+
}
|
|
1093
|
+
if (!blocked && !rel.includes("node_modules")) files.add(rel);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return [...files].sort();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
async function readFiles(paths) {
|
|
1100
|
+
const result = {};
|
|
1101
|
+
for (const p of paths) {
|
|
1102
|
+
try { result[p] = await readFile(p, "utf-8"); } catch {}
|
|
1103
|
+
}
|
|
1104
|
+
return result;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async function callClaude(prompt, fileContents) {
|
|
1108
|
+
const fileList = Object.entries(fileContents)
|
|
1109
|
+
.map(([path, content]) => \`--- \${path} ---\\n\${content}\`).join("\\n\\n");
|
|
1110
|
+
|
|
1111
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
1112
|
+
method: "POST",
|
|
1113
|
+
headers: {
|
|
1114
|
+
"Content-Type": "application/json",
|
|
1115
|
+
"x-api-key": ANTHROPIC_API_KEY,
|
|
1116
|
+
"anthropic-version": "2023-06-01",
|
|
1117
|
+
},
|
|
1118
|
+
body: JSON.stringify({
|
|
1119
|
+
model: "claude-sonnet-4-20250514",
|
|
1120
|
+
max_tokens: 16384,
|
|
1121
|
+
system: \`You are Anteater, an AI coding agent. You modify web application source files based on user requests.
|
|
1122
|
+
RULES: Make minimal, focused changes. Only modify files that need to change. Preserve existing code style.
|
|
1123
|
+
Never modify environment files, API routes, or configuration.
|
|
1124
|
+
CRITICAL: The "path" in each output object MUST exactly match one of the input file paths. Do NOT shorten, rename, or strip prefixes from paths.
|
|
1125
|
+
OUTPUT FORMAT: Return a JSON array of objects with "path" and "content". Return ONLY valid JSON, no markdown fences.
|
|
1126
|
+
If no changes are needed, return an empty array: []\`,
|
|
1127
|
+
messages: [{ role: "user", content: \`Files:\\n\\n\${fileList}\\n\\nRequest: \${prompt}\` }],
|
|
1128
|
+
}),
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
if (!res.ok) throw new Error(\`Anthropic API error \${res.status}: \${await res.text()}\`);
|
|
1132
|
+
const data = await res.json();
|
|
1133
|
+
if (data.stop_reason === "max_tokens") throw new Error("Response truncated — max_tokens exceeded");
|
|
1134
|
+
return JSON.parse(data.content?.[0]?.text || "[]");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
export async function main() {
|
|
1138
|
+
console.log(\`Anteater agent: "\${args.prompt}"\`);
|
|
1139
|
+
const paths = await collectFiles();
|
|
1140
|
+
console.log(\`Found \${paths.length} editable files\`);
|
|
1141
|
+
if (!paths.length) { console.log("No files matched."); process.exit(0); }
|
|
1142
|
+
|
|
1143
|
+
const contents = await readFiles(paths);
|
|
1144
|
+
console.log("Calling Claude...");
|
|
1145
|
+
const changes = await callClaude(args.prompt, contents);
|
|
1146
|
+
|
|
1147
|
+
if (!changes?.length) { console.log("No changes needed."); process.exit(0); }
|
|
1148
|
+
|
|
1149
|
+
// Validate returned paths match input files
|
|
1150
|
+
const validPathSet = new Set(Object.keys(contents));
|
|
1151
|
+
const validated = [];
|
|
1152
|
+
for (const change of changes) {
|
|
1153
|
+
if (validPathSet.has(change.path)) {
|
|
1154
|
+
validated.push(change);
|
|
1155
|
+
} else {
|
|
1156
|
+
console.warn(\` Rejected: \${change.path} (not in allowed input files)\`);
|
|
1157
|
+
const basename = change.path.split("/").pop();
|
|
1158
|
+
const match = [...validPathSet].find((p) => p.endsWith("/" + basename));
|
|
1159
|
+
if (match) {
|
|
1160
|
+
console.log(\` Corrected: \${change.path} -> \${match}\`);
|
|
1161
|
+
validated.push({ path: match, content: change.content });
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if (!validated.length) { console.log("No valid changes after path validation."); process.exit(0); }
|
|
1167
|
+
console.log(\`Modifying \${validated.length} file(s)\`);
|
|
1168
|
+
for (const { path, content } of validated) {
|
|
1169
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1170
|
+
await writeFile(path, content, "utf-8");
|
|
1171
|
+
console.log(\` Updated: \${path}\`);
|
|
1172
|
+
}
|
|
1173
|
+
console.log("Done!");
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const isEntryPoint = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
1177
|
+
if (isEntryPoint) {
|
|
1178
|
+
main().catch((err) => { console.error("Agent failed:", err); process.exit(1); });
|
|
1179
|
+
}
|
|
1180
|
+
`;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Patch the layout file to include AnteaterBar.
|
|
1185
|
+
*/
|
|
1186
|
+
export async function patchLayout(layoutPath, cwd) {
|
|
1187
|
+
const fullPath = join(cwd, layoutPath);
|
|
1188
|
+
let content = await readFile(fullPath, "utf-8");
|
|
1189
|
+
|
|
1190
|
+
// Don't patch if already has AnteaterBar
|
|
1191
|
+
if (content.includes("AnteaterBar")) {
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Add import at the top (after last import line)
|
|
1196
|
+
const importLine = `import { AnteaterBar } from "next-anteater";\n`;
|
|
1197
|
+
const lastImportIdx = content.lastIndexOf("import ");
|
|
1198
|
+
if (lastImportIdx !== -1) {
|
|
1199
|
+
const endOfLine = content.indexOf("\n", lastImportIdx);
|
|
1200
|
+
content = content.slice(0, endOfLine + 1) + importLine + content.slice(endOfLine + 1);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Add <AnteaterBar /> before closing </body>
|
|
1204
|
+
content = content.replace(
|
|
1205
|
+
/([ \t]*)<\/body>/,
|
|
1206
|
+
`$1 <AnteaterBar />\n$1</body>`
|
|
1207
|
+
);
|
|
1208
|
+
|
|
1209
|
+
await writeFile(fullPath, content, "utf-8");
|
|
1210
|
+
return true;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Write all scaffolded files.
|
|
1215
|
+
*/
|
|
1216
|
+
export async function scaffoldFiles(cwd, options) {
|
|
1217
|
+
const results = [];
|
|
1218
|
+
|
|
1219
|
+
// anteater.config
|
|
1220
|
+
const config = generateConfig(options);
|
|
1221
|
+
if (await writeIfNotExists(join(cwd, config.filename), config.content)) {
|
|
1222
|
+
results.push(config.filename);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// API route
|
|
1226
|
+
const route = generateApiRoute(options);
|
|
1227
|
+
const routeDir = options.isAppRouter ? "app/api/anteater" : "pages/api/anteater";
|
|
1228
|
+
const routePath = join(cwd, routeDir, route.filename);
|
|
1229
|
+
const createdRoute = await writeIfNotExists(routePath, route.content);
|
|
1230
|
+
if (createdRoute) {
|
|
1231
|
+
results.push(join(routeDir, route.filename));
|
|
1232
|
+
} else if (await patchApiRouteMutationGuardIfMissing(routePath)) {
|
|
1233
|
+
results.push(`${join(routeDir, route.filename)} (patched same-origin guard)`);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Runs API route (multi-run discovery)
|
|
1237
|
+
const runsRoute = generateRunsRoute(options);
|
|
1238
|
+
const runsDir = options.isAppRouter ? "app/api/anteater/runs" : "pages/api/anteater/runs";
|
|
1239
|
+
const runsPath = join(cwd, runsDir, runsRoute.filename);
|
|
1240
|
+
const createdRunsRoute = await writeIfNotExists(runsPath, runsRoute.content);
|
|
1241
|
+
if (createdRunsRoute) {
|
|
1242
|
+
results.push(join(runsDir, runsRoute.filename));
|
|
1243
|
+
} else {
|
|
1244
|
+
if (await patchRunsRouteDeleteIfMissing(runsPath, options.isTypeScript)) {
|
|
1245
|
+
results.push(`${join(runsDir, runsRoute.filename)} (patched DELETE handler)`);
|
|
1246
|
+
}
|
|
1247
|
+
if (await patchRunsRouteMutationGuardIfMissing(runsPath)) {
|
|
1248
|
+
results.push(`${join(runsDir, runsRoute.filename)} (patched same-origin guard)`);
|
|
1249
|
+
}
|
|
1250
|
+
if (await patchRunsRouteFailedTtlIfMissing(runsPath)) {
|
|
1251
|
+
results.push(`${join(runsDir, runsRoute.filename)} (patched failed-run TTL)`);
|
|
1252
|
+
}
|
|
1253
|
+
if (await patchRunsRouteDeploymentCompletionIfNeeded(runsPath)) {
|
|
1254
|
+
results.push(`${join(runsDir, runsRoute.filename)} (patched deploy completion detection)`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// GitHub Action workflow
|
|
1259
|
+
const workflowPath = join(cwd, ".github/workflows/anteater.yml");
|
|
1260
|
+
if (await writeIfNotExists(workflowPath, generateWorkflow(options))) {
|
|
1261
|
+
results.push(".github/workflows/anteater.yml");
|
|
1262
|
+
} else if (await patchWorkflowModelInputIfPresent(workflowPath)) {
|
|
1263
|
+
results.push(".github/workflows/anteater.yml (patched deprecated model input)");
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Claude Code agent settings (always overwrite — reflects current choices)
|
|
1267
|
+
if (options.model && options.permissionsMode) {
|
|
1268
|
+
const settingsPath = join(cwd, ".claude/settings.local.json");
|
|
1269
|
+
await mkdir(dirname(settingsPath), { recursive: true });
|
|
1270
|
+
await writeFile(settingsPath, generateClaudeSettings(options), "utf-8");
|
|
1271
|
+
results.push(".claude/settings.local.json");
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Patch layout
|
|
1275
|
+
if (options.layoutFile) {
|
|
1276
|
+
if (await patchLayout(options.layoutFile, cwd)) {
|
|
1277
|
+
results.push(`${options.layoutFile} (patched)`);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
return results;
|
|
1282
|
+
}
|