next-anteater 0.1.0
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/bin/setup-anteater.mjs +7 -0
- package/lib/detect.mjs +106 -0
- package/lib/scaffold.mjs +631 -0
- package/lib/secrets.mjs +165 -0
- package/lib/setup.mjs +316 -0
- package/lib/ui.mjs +102 -0
- package/package.json +15 -0
package/lib/detect.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project detection — figures out what kind of project we're in.
|
|
3
|
+
*/
|
|
4
|
+
import { readFile, access } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
async function fileExists(path) {
|
|
9
|
+
try {
|
|
10
|
+
await access(path);
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readJson(path) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(await readFile(path, "utf-8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function detectProject(cwd) {
|
|
26
|
+
const result = {
|
|
27
|
+
isNextJs: false,
|
|
28
|
+
nextVersion: null,
|
|
29
|
+
isAppRouter: false,
|
|
30
|
+
isPagesRouter: false,
|
|
31
|
+
isTypeScript: false,
|
|
32
|
+
hasGit: false,
|
|
33
|
+
gitRemote: null, // "owner/repo"
|
|
34
|
+
hasPnpm: false,
|
|
35
|
+
hasYarn: false,
|
|
36
|
+
hasNpm: false,
|
|
37
|
+
packageManager: "npm",
|
|
38
|
+
layoutFile: null,
|
|
39
|
+
rootDir: cwd,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Check package.json
|
|
43
|
+
const pkg = await readJson(join(cwd, "package.json"));
|
|
44
|
+
if (pkg) {
|
|
45
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
46
|
+
if (deps.next) {
|
|
47
|
+
result.isNextJs = true;
|
|
48
|
+
result.nextVersion = deps.next.replace(/[\^~]/, "");
|
|
49
|
+
}
|
|
50
|
+
result.isTypeScript = !!deps.typescript;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Detect router type
|
|
54
|
+
if (await fileExists(join(cwd, "app", "layout.tsx")) || await fileExists(join(cwd, "app", "layout.js"))) {
|
|
55
|
+
result.isAppRouter = true;
|
|
56
|
+
result.layoutFile = (await fileExists(join(cwd, "app", "layout.tsx")))
|
|
57
|
+
? "app/layout.tsx"
|
|
58
|
+
: "app/layout.js";
|
|
59
|
+
}
|
|
60
|
+
if (await fileExists(join(cwd, "pages", "_app.tsx")) || await fileExists(join(cwd, "pages", "_app.js"))) {
|
|
61
|
+
result.isPagesRouter = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Detect package manager
|
|
65
|
+
if (await fileExists(join(cwd, "pnpm-lock.yaml")) || await fileExists(join(cwd, "pnpm-workspace.yaml"))) {
|
|
66
|
+
result.hasPnpm = true;
|
|
67
|
+
result.packageManager = "pnpm";
|
|
68
|
+
} else if (await fileExists(join(cwd, "yarn.lock"))) {
|
|
69
|
+
result.hasYarn = true;
|
|
70
|
+
result.packageManager = "yarn";
|
|
71
|
+
} else {
|
|
72
|
+
result.hasNpm = true;
|
|
73
|
+
result.packageManager = "npm";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Detect git
|
|
77
|
+
if (await fileExists(join(cwd, ".git"))) {
|
|
78
|
+
result.hasGit = true;
|
|
79
|
+
try {
|
|
80
|
+
const remote = execSync("git remote get-url origin", { cwd, encoding: "utf-8" }).trim();
|
|
81
|
+
// Extract owner/repo from various URL formats
|
|
82
|
+
const match = remote.match(/github\.com[:/]([^/]+\/[^/.]+)/);
|
|
83
|
+
if (match) {
|
|
84
|
+
result.gitRemote = match[1].replace(/\.git$/, "");
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// No remote configured
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Detect default branch (what HEAD points to on the remote)
|
|
91
|
+
try {
|
|
92
|
+
const head = execSync("git symbolic-ref refs/remotes/origin/HEAD", { cwd, encoding: "utf-8" }).trim();
|
|
93
|
+
result.defaultBranch = head.replace("refs/remotes/origin/", "");
|
|
94
|
+
} catch {
|
|
95
|
+
// Fallback: check local HEAD branch name
|
|
96
|
+
try {
|
|
97
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd, encoding: "utf-8" }).trim();
|
|
98
|
+
result.defaultBranch = branch;
|
|
99
|
+
} catch {
|
|
100
|
+
result.defaultBranch = "main";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return result;
|
|
106
|
+
}
|
package/lib/scaffold.mjs
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
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
|
+
/**
|
|
19
|
+
* Generate anteater.config.ts
|
|
20
|
+
*/
|
|
21
|
+
export function generateConfig({ repo, allowedGlobs, blockedGlobs, autoMerge, isTypeScript, productionBranch }) {
|
|
22
|
+
const ext = isTypeScript ? "ts" : "js";
|
|
23
|
+
const typeImport = isTypeScript
|
|
24
|
+
? `import type { AnteaterConfig } from "@anteater/next";\n\n`
|
|
25
|
+
: "";
|
|
26
|
+
const typeAnnotation = isTypeScript ? ": AnteaterConfig" : "";
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
filename: `anteater.config.${ext}`,
|
|
30
|
+
content: `${typeImport}const config${typeAnnotation} = {
|
|
31
|
+
repo: "${repo}",
|
|
32
|
+
productionBranch: "${productionBranch}",
|
|
33
|
+
modes: ["prod", "copy"],
|
|
34
|
+
autoMerge: ${autoMerge},
|
|
35
|
+
|
|
36
|
+
allowedGlobs: [
|
|
37
|
+
${allowedGlobs.map((g) => ` "${g}",`).join("\n")}
|
|
38
|
+
],
|
|
39
|
+
|
|
40
|
+
blockedGlobs: [
|
|
41
|
+
${blockedGlobs.map((g) => ` "${g}",`).join("\n")}
|
|
42
|
+
],
|
|
43
|
+
|
|
44
|
+
requireReviewFor: ["auth", "billing", "payments", "dependencies"],
|
|
45
|
+
maxFilesChanged: 20,
|
|
46
|
+
maxDiffBytes: 120000,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default config;
|
|
50
|
+
`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generate API route handler.
|
|
56
|
+
*
|
|
57
|
+
* Uses string concatenation instead of nested template literals to avoid
|
|
58
|
+
* escape-sequence hell (template literals inside template literals).
|
|
59
|
+
*/
|
|
60
|
+
export function generateApiRoute({ isTypeScript, productionBranch }) {
|
|
61
|
+
const ext = isTypeScript ? "ts" : "js";
|
|
62
|
+
const TS = isTypeScript; // shorthand
|
|
63
|
+
const lines = [];
|
|
64
|
+
const add = (s) => lines.push(s);
|
|
65
|
+
|
|
66
|
+
// --- Imports ---
|
|
67
|
+
add('import { NextRequest, NextResponse } from "next/server";');
|
|
68
|
+
if (TS) add('import type { AnteaterRequest, AnteaterResponse, AnteaterStatusResponse } from "@anteater/next";');
|
|
69
|
+
add("");
|
|
70
|
+
|
|
71
|
+
// --- Helpers ---
|
|
72
|
+
add("/** Auto-detect repo from Vercel system env vars, fall back to ANTEATER_GITHUB_REPO */");
|
|
73
|
+
add("function getRepo()" + (TS ? ": string | undefined" : "") + " {");
|
|
74
|
+
add(" if (process.env.ANTEATER_GITHUB_REPO) return process.env.ANTEATER_GITHUB_REPO;");
|
|
75
|
+
add(" const owner = process.env.VERCEL_GIT_REPO_OWNER;");
|
|
76
|
+
add(" const slug = process.env.VERCEL_GIT_REPO_SLUG;");
|
|
77
|
+
add(" if (owner && slug) return `${owner}/${slug}`;");
|
|
78
|
+
add(" return undefined;");
|
|
79
|
+
add("}");
|
|
80
|
+
add("");
|
|
81
|
+
add("function ghFetch(url" + (TS ? ": string" : "") + ") {");
|
|
82
|
+
add(" const token = process.env.GITHUB_TOKEN;");
|
|
83
|
+
add(" return fetch(url, {");
|
|
84
|
+
add(" headers: {");
|
|
85
|
+
add(" Authorization: `Bearer ${token}`,");
|
|
86
|
+
add(' Accept: "application/vnd.github+json",');
|
|
87
|
+
add(' "X-GitHub-Api-Version": "2022-11-28",');
|
|
88
|
+
add(" },");
|
|
89
|
+
add(' cache: "no-store",');
|
|
90
|
+
add(" });");
|
|
91
|
+
add("}");
|
|
92
|
+
add("");
|
|
93
|
+
add("/** Return status response with deployment ID for client-side deploy detection */");
|
|
94
|
+
add("function status(body" + (TS ? ": AnteaterStatusResponse" : "") + ", httpStatus" + (TS ? "?: number" : "") + ") {");
|
|
95
|
+
add(" const deploymentId = process.env.VERCEL_DEPLOYMENT_ID;");
|
|
96
|
+
add(" return NextResponse.json({ ...body, deploymentId }, httpStatus ? { status: httpStatus } : undefined);");
|
|
97
|
+
add("}");
|
|
98
|
+
add("");
|
|
99
|
+
|
|
100
|
+
// --- POST handler ---
|
|
101
|
+
add("export async function POST(request" + (TS ? ": NextRequest" : "") + ") {");
|
|
102
|
+
add(" try {");
|
|
103
|
+
add(" const body" + (TS ? ": AnteaterRequest" : "") + " = await request.json();");
|
|
104
|
+
add("");
|
|
105
|
+
add(" if (!body.prompt?.trim()) {");
|
|
106
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "(");
|
|
107
|
+
add(' { requestId: "", branch: "", status: "error", error: "Prompt is required" },');
|
|
108
|
+
add(" { status: 400 }");
|
|
109
|
+
add(" );");
|
|
110
|
+
add(" }");
|
|
111
|
+
add("");
|
|
112
|
+
add(" // Auth: sec-fetch-site for same-origin (AnteaterBar), x-anteater-secret for external");
|
|
113
|
+
add(" const secret = process.env.ANTEATER_SECRET;");
|
|
114
|
+
add(" if (secret) {");
|
|
115
|
+
add(' const fetchSite = request.headers.get("sec-fetch-site");');
|
|
116
|
+
add(' const isSameOrigin = fetchSite === "same-origin";');
|
|
117
|
+
add(" if (!isSameOrigin) {");
|
|
118
|
+
add(' const authHeader = request.headers.get("x-anteater-secret");');
|
|
119
|
+
add(" if (authHeader !== secret) {");
|
|
120
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "(");
|
|
121
|
+
add(' { requestId: "", branch: "", status: "error", error: "Unauthorized" },');
|
|
122
|
+
add(" { status: 401 }");
|
|
123
|
+
add(" );");
|
|
124
|
+
add(" }");
|
|
125
|
+
add(" }");
|
|
126
|
+
add(" }");
|
|
127
|
+
add("");
|
|
128
|
+
add(" const repo = getRepo();");
|
|
129
|
+
add(" const token = process.env.GITHUB_TOKEN;");
|
|
130
|
+
add(" if (!repo || !token) {");
|
|
131
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "(");
|
|
132
|
+
add(' { requestId: "", branch: "", status: "error", error: "Server misconfigured" },');
|
|
133
|
+
add(" { status: 500 }");
|
|
134
|
+
add(" );");
|
|
135
|
+
add(" }");
|
|
136
|
+
add("");
|
|
137
|
+
add(" const requestId = crypto.randomUUID().slice(0, 8);");
|
|
138
|
+
add(" const branch = body.mode === \"copy\"");
|
|
139
|
+
add(" ? `anteater/friend-${requestId}`");
|
|
140
|
+
add(" : `anteater/run-${requestId}`;");
|
|
141
|
+
add("");
|
|
142
|
+
add(" const dispatchRes = await fetch(");
|
|
143
|
+
add(" `https://api.github.com/repos/${repo}/actions/workflows/anteater.yml/dispatches`,");
|
|
144
|
+
add(" {");
|
|
145
|
+
add(' method: "POST",');
|
|
146
|
+
add(" headers: {");
|
|
147
|
+
add(" Authorization: `Bearer ${token}`,");
|
|
148
|
+
add(' Accept: "application/vnd.github+json",');
|
|
149
|
+
add(' "X-GitHub-Api-Version": "2022-11-28",');
|
|
150
|
+
add(" },");
|
|
151
|
+
add(" body: JSON.stringify({");
|
|
152
|
+
add(' ref: "' + productionBranch + '",');
|
|
153
|
+
add(" inputs: {");
|
|
154
|
+
add(" requestId,");
|
|
155
|
+
add(" prompt: body.prompt,");
|
|
156
|
+
add(' mode: body.mode || "prod",');
|
|
157
|
+
add(" branch,");
|
|
158
|
+
add(' baseBranch: "' + productionBranch + '",');
|
|
159
|
+
add(' autoMerge: String(body.mode !== "copy"),');
|
|
160
|
+
add(" },");
|
|
161
|
+
add(" }),");
|
|
162
|
+
add(" }");
|
|
163
|
+
add(" );");
|
|
164
|
+
add("");
|
|
165
|
+
add(" if (!dispatchRes.ok) {");
|
|
166
|
+
add(" const err = await dispatchRes.text();");
|
|
167
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "(");
|
|
168
|
+
add(" { requestId, branch, status: \"error\", error: `GitHub dispatch failed: ${dispatchRes.status}` },");
|
|
169
|
+
add(" { status: 502 }");
|
|
170
|
+
add(" );");
|
|
171
|
+
add(" }");
|
|
172
|
+
add("");
|
|
173
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "({ requestId, branch, status: \"queued\" });");
|
|
174
|
+
add(" } catch {");
|
|
175
|
+
add(" return NextResponse.json" + (TS ? "<AnteaterResponse>" : "") + "(");
|
|
176
|
+
add(' { requestId: "", branch: "", status: "error", error: "Invalid request body" },');
|
|
177
|
+
add(" { status: 400 }");
|
|
178
|
+
add(" );");
|
|
179
|
+
add(" }");
|
|
180
|
+
add("}");
|
|
181
|
+
add("");
|
|
182
|
+
|
|
183
|
+
// --- GET handler (status polling) ---
|
|
184
|
+
add("/**");
|
|
185
|
+
add(" * GET /api/anteater?branch=anteater/run-xxx");
|
|
186
|
+
add(" * Polls pipeline status. Deploy detection handled client-side via deployment ID.");
|
|
187
|
+
add(" */");
|
|
188
|
+
add("export async function GET(request" + (TS ? ": NextRequest" : "") + ") {");
|
|
189
|
+
add(" const branch = request.nextUrl.searchParams.get(\"branch\");");
|
|
190
|
+
add(" if (!branch) {");
|
|
191
|
+
add(' return status({ step: "error", completed: true, error: "Missing branch param" }, 400);');
|
|
192
|
+
add(" }");
|
|
193
|
+
add("");
|
|
194
|
+
add(" const repo = getRepo();");
|
|
195
|
+
add(" const token = process.env.GITHUB_TOKEN;");
|
|
196
|
+
add(" if (!repo || !token) {");
|
|
197
|
+
add(' return status({ step: "error", completed: true, error: "Server misconfigured" }, 500);');
|
|
198
|
+
add(" }");
|
|
199
|
+
add("");
|
|
200
|
+
add(" try {");
|
|
201
|
+
add(" const prRes = await ghFetch(");
|
|
202
|
+
add(" `https://api.github.com/repos/${repo}/pulls?head=${repo.split(\"/\")[0]}:${branch}&state=all&per_page=1`,");
|
|
203
|
+
add(" );");
|
|
204
|
+
add(" if (prRes.ok) {");
|
|
205
|
+
add(" const prs = await prRes.json();");
|
|
206
|
+
add(" if (prs.length) {");
|
|
207
|
+
add(" const pr = prs[0];");
|
|
208
|
+
add(" if (pr.merged_at) {");
|
|
209
|
+
add(" const mergedAgo = Date.now() - new Date(pr.merged_at).getTime();");
|
|
210
|
+
add(" const step = mergedAgo > 150000 ? \"done\" : \"redeploying\";");
|
|
211
|
+
add(" return status({ step, completed: step === \"done\" });");
|
|
212
|
+
add(" }");
|
|
213
|
+
add(" if (pr.state === \"closed\") {");
|
|
214
|
+
add(' return status({ step: "error", completed: true, error: "PR was closed without merging" });');
|
|
215
|
+
add(" }");
|
|
216
|
+
add(" return status({ step: \"merging\", completed: false });");
|
|
217
|
+
add(" }");
|
|
218
|
+
add(" }");
|
|
219
|
+
add("");
|
|
220
|
+
add(" const branchRes = await ghFetch(");
|
|
221
|
+
add(" `https://api.github.com/repos/${repo}/git/refs/heads/${branch}`,");
|
|
222
|
+
add(" );");
|
|
223
|
+
add(" if (branchRes.ok) {");
|
|
224
|
+
add(" return status({ step: \"merging\", completed: false });");
|
|
225
|
+
add(" }");
|
|
226
|
+
add("");
|
|
227
|
+
add(" const runsRes = await ghFetch(");
|
|
228
|
+
add(" `https://api.github.com/repos/${repo}/actions/workflows/anteater.yml/runs?per_page=5`,");
|
|
229
|
+
add(" );");
|
|
230
|
+
add(" if (runsRes.ok) {");
|
|
231
|
+
add(" const { workflow_runs: runs } = await runsRes.json();");
|
|
232
|
+
add(" const recentFailed = runs?.find(");
|
|
233
|
+
add(' (r' + (TS ? ": { status: string; conclusion: string; created_at: string }" : "") + ') => r.status === "completed" && r.conclusion === "failure" &&');
|
|
234
|
+
add(" Date.now() - new Date(r.created_at).getTime() < 5 * 60 * 1000,");
|
|
235
|
+
add(" );");
|
|
236
|
+
add(" if (recentFailed) {");
|
|
237
|
+
add(' return status({ step: "error", completed: true, error: "Workflow failed — check GitHub Actions" });');
|
|
238
|
+
add(" }");
|
|
239
|
+
add(" }");
|
|
240
|
+
add("");
|
|
241
|
+
add(" return status({ step: \"working\", completed: false });");
|
|
242
|
+
add(" } catch {");
|
|
243
|
+
add(' return status({ step: "error", completed: true, error: "Status check failed" }, 500);');
|
|
244
|
+
add(" }");
|
|
245
|
+
add("}");
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
filename: `route.${ext}`,
|
|
249
|
+
content: lines.join("\n") + "\n",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Generate .claude/settings.local.json for agent permissions.
|
|
255
|
+
*/
|
|
256
|
+
export function generateClaudeSettings({ model, permissionsMode }) {
|
|
257
|
+
if (permissionsMode === "unrestricted") {
|
|
258
|
+
return JSON.stringify({
|
|
259
|
+
model,
|
|
260
|
+
alwaysThinkingEnabled: true,
|
|
261
|
+
skipDangerousModePermissionPrompt: true,
|
|
262
|
+
permissions: {
|
|
263
|
+
defaultMode: "bypassPermissions",
|
|
264
|
+
allow: [
|
|
265
|
+
"Bash", "Edit", "Write", "MultiEdit", "NotebookEdit",
|
|
266
|
+
"WebFetch", "WebSearch", "Skill", "mcp__*",
|
|
267
|
+
],
|
|
268
|
+
deny: [],
|
|
269
|
+
},
|
|
270
|
+
}, null, 2) + "\n";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Sandboxed (default)
|
|
274
|
+
return JSON.stringify({
|
|
275
|
+
model,
|
|
276
|
+
alwaysThinkingEnabled: true,
|
|
277
|
+
skipDangerousModePermissionPrompt: true,
|
|
278
|
+
permissions: {
|
|
279
|
+
defaultMode: "bypassPermissions",
|
|
280
|
+
allow: [
|
|
281
|
+
"Read", "Edit", "Write", "Glob", "Grep",
|
|
282
|
+
"Bash(git *)", "Bash(npm *)", "Bash(pnpm *)",
|
|
283
|
+
"Bash(npx *)", "Bash(node *)", "Bash(ls *)",
|
|
284
|
+
"Bash(find *)", "Bash(mkdir *)", "Bash(rm *)",
|
|
285
|
+
"Bash(cp *)", "Bash(mv *)",
|
|
286
|
+
],
|
|
287
|
+
deny: [
|
|
288
|
+
"WebFetch", "WebSearch",
|
|
289
|
+
"Bash(curl *)", "Bash(wget *)",
|
|
290
|
+
"Bash(gh *)", "Bash(vercel *)",
|
|
291
|
+
"mcp__*",
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
}, null, 2) + "\n";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Generate the GitHub Actions workflow.
|
|
299
|
+
*/
|
|
300
|
+
export function generateWorkflow({ allowedGlobs, blockedGlobs, productionBranch, model }) {
|
|
301
|
+
const allowed = allowedGlobs.join(", ");
|
|
302
|
+
const blocked = blockedGlobs.join(", ");
|
|
303
|
+
|
|
304
|
+
return `name: Anteater Apply
|
|
305
|
+
|
|
306
|
+
on:
|
|
307
|
+
workflow_dispatch:
|
|
308
|
+
inputs:
|
|
309
|
+
requestId:
|
|
310
|
+
description: "Unique request ID"
|
|
311
|
+
required: true
|
|
312
|
+
prompt:
|
|
313
|
+
description: "Natural language change request"
|
|
314
|
+
required: true
|
|
315
|
+
mode:
|
|
316
|
+
description: "prod or copy"
|
|
317
|
+
required: true
|
|
318
|
+
default: "prod"
|
|
319
|
+
branch:
|
|
320
|
+
description: "Branch to create and commit to"
|
|
321
|
+
required: true
|
|
322
|
+
baseBranch:
|
|
323
|
+
description: "Base branch to fork from"
|
|
324
|
+
required: true
|
|
325
|
+
default: "${productionBranch}"
|
|
326
|
+
autoMerge:
|
|
327
|
+
description: "Auto-merge the PR if true"
|
|
328
|
+
required: false
|
|
329
|
+
default: "true"
|
|
330
|
+
|
|
331
|
+
permissions:
|
|
332
|
+
contents: write
|
|
333
|
+
pull-requests: write
|
|
334
|
+
id-token: write
|
|
335
|
+
|
|
336
|
+
jobs:
|
|
337
|
+
apply:
|
|
338
|
+
runs-on: ubuntu-latest
|
|
339
|
+
timeout-minutes: 360
|
|
340
|
+
steps:
|
|
341
|
+
- name: Checkout base branch
|
|
342
|
+
uses: actions/checkout@v4
|
|
343
|
+
with:
|
|
344
|
+
ref: \${{ inputs.baseBranch }}
|
|
345
|
+
fetch-depth: 0
|
|
346
|
+
|
|
347
|
+
- name: Create and switch to target branch
|
|
348
|
+
run: git checkout -b "\${{ inputs.branch }}"
|
|
349
|
+
|
|
350
|
+
- name: Setup Node.js
|
|
351
|
+
uses: actions/setup-node@v4
|
|
352
|
+
with:
|
|
353
|
+
node-version: 22
|
|
354
|
+
|
|
355
|
+
- name: Install dependencies
|
|
356
|
+
run: |
|
|
357
|
+
npm install -g pnpm@9 --silent
|
|
358
|
+
pnpm install --frozen-lockfile
|
|
359
|
+
|
|
360
|
+
- name: Run Anteater agent
|
|
361
|
+
uses: anthropics/claude-code-action@v1
|
|
362
|
+
with:
|
|
363
|
+
prompt: |
|
|
364
|
+
You are Anteater, an AI agent that modifies a web app based on user requests.
|
|
365
|
+
|
|
366
|
+
USER REQUEST: \${{ inputs.prompt }}
|
|
367
|
+
|
|
368
|
+
RULES:
|
|
369
|
+
- Only edit files under: ${allowed}
|
|
370
|
+
- NEVER edit: ${blocked}
|
|
371
|
+
- Make minimal, focused changes
|
|
372
|
+
- Preserve existing code style
|
|
373
|
+
- After making changes, run the build command to verify the build passes
|
|
374
|
+
- If the build fails, read the error output and fix the issues, then build again
|
|
375
|
+
- Keep iterating until the build passes or you've tried 3 times
|
|
376
|
+
- Do NOT commit — just leave the changed files on disk
|
|
377
|
+
|
|
378
|
+
IMPORTANT: Always verify your changes compile by running the build command.
|
|
379
|
+
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
|
380
|
+
model: "${model}"
|
|
381
|
+
claude_args: "--allowedTools Edit,Read,Write,Bash,Glob,Grep --max-turns 25"
|
|
382
|
+
|
|
383
|
+
- name: Check for changes
|
|
384
|
+
id: changes
|
|
385
|
+
run: |
|
|
386
|
+
git add -A
|
|
387
|
+
if git diff --staged --quiet; then
|
|
388
|
+
echo "has_changes=false" >> "\\$GITHUB_OUTPUT"
|
|
389
|
+
else
|
|
390
|
+
echo "has_changes=true" >> "\\$GITHUB_OUTPUT"
|
|
391
|
+
fi
|
|
392
|
+
|
|
393
|
+
- name: Commit changes
|
|
394
|
+
if: steps.changes.outputs.has_changes == 'true'
|
|
395
|
+
env:
|
|
396
|
+
PROMPT: \${{ inputs.prompt }}
|
|
397
|
+
run: |
|
|
398
|
+
git config user.name "anteater[bot]"
|
|
399
|
+
git config user.email "anteater[bot]@users.noreply.github.com"
|
|
400
|
+
git commit -m "anteater: \${PROMPT}"
|
|
401
|
+
|
|
402
|
+
- name: Push branch
|
|
403
|
+
if: steps.changes.outputs.has_changes == 'true'
|
|
404
|
+
run: |
|
|
405
|
+
git remote set-url origin "https://x-access-token:\${GITHUB_TOKEN}@github.com/\${{ github.repository }}.git"
|
|
406
|
+
git push origin "\${{ inputs.branch }}"
|
|
407
|
+
env:
|
|
408
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
409
|
+
|
|
410
|
+
- name: Create pull request
|
|
411
|
+
if: steps.changes.outputs.has_changes == 'true'
|
|
412
|
+
env:
|
|
413
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
414
|
+
PROMPT: \${{ inputs.prompt }}
|
|
415
|
+
REQUEST_ID: \${{ inputs.requestId }}
|
|
416
|
+
MODE: \${{ inputs.mode }}
|
|
417
|
+
run: |
|
|
418
|
+
gh pr create \\
|
|
419
|
+
--base "\${{ inputs.baseBranch }}" \\
|
|
420
|
+
--head "\${{ inputs.branch }}" \\
|
|
421
|
+
--title "anteater: \${PROMPT}" \\
|
|
422
|
+
--body "Automated change by Anteater (request \\\`\${REQUEST_ID}\\\`).
|
|
423
|
+
|
|
424
|
+
**Prompt:** \${PROMPT}
|
|
425
|
+
**Mode:** \${MODE}"
|
|
426
|
+
|
|
427
|
+
- name: Auto-merge PR
|
|
428
|
+
if: steps.changes.outputs.has_changes == 'true' && inputs.autoMerge == 'true'
|
|
429
|
+
env:
|
|
430
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
431
|
+
run: gh pr merge "\${{ inputs.branch }}" --squash --delete-branch
|
|
432
|
+
`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Generate the AI apply script.
|
|
437
|
+
*/
|
|
438
|
+
export function generateApplyScript() {
|
|
439
|
+
// Read from the existing script in the monorepo — or inline it
|
|
440
|
+
return `#!/usr/bin/env node
|
|
441
|
+
|
|
442
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
443
|
+
import { dirname, relative, resolve } from "node:path";
|
|
444
|
+
import { glob } from "node:fs/promises";
|
|
445
|
+
import { parseArgs } from "node:util";
|
|
446
|
+
|
|
447
|
+
const { values: args } = parseArgs({
|
|
448
|
+
options: {
|
|
449
|
+
prompt: { type: "string" },
|
|
450
|
+
"allowed-paths": { type: "string" },
|
|
451
|
+
"blocked-paths": { type: "string", default: "" },
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
456
|
+
if (!ANTHROPIC_API_KEY) { console.error("Missing ANTHROPIC_API_KEY"); process.exit(1); }
|
|
457
|
+
if (!args.prompt) { console.error("Missing --prompt"); process.exit(1); }
|
|
458
|
+
|
|
459
|
+
const allowedGlobs = args["allowed-paths"]?.split(",").map((s) => s.trim()) ?? [];
|
|
460
|
+
const blockedGlobs = args["blocked-paths"]?.split(",").filter(Boolean).map((s) => s.trim()) ?? [];
|
|
461
|
+
|
|
462
|
+
async function collectFiles() {
|
|
463
|
+
const files = new Set();
|
|
464
|
+
for (const pattern of allowedGlobs) {
|
|
465
|
+
for await (const entry of glob(pattern)) {
|
|
466
|
+
const rel = relative(process.cwd(), resolve(entry)).replace(/\\\\/g, "/");
|
|
467
|
+
let blocked = false;
|
|
468
|
+
for (const bp of blockedGlobs) {
|
|
469
|
+
const prefix = bp.replace(/\\/?\\*\\*?$/, "");
|
|
470
|
+
if (rel === prefix || rel.startsWith(prefix + "/")) { blocked = true; break; }
|
|
471
|
+
}
|
|
472
|
+
if (!blocked && !rel.includes("node_modules")) files.add(rel);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return [...files].sort();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function readFiles(paths) {
|
|
479
|
+
const result = {};
|
|
480
|
+
for (const p of paths) {
|
|
481
|
+
try { result[p] = await readFile(p, "utf-8"); } catch {}
|
|
482
|
+
}
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function callClaude(prompt, fileContents) {
|
|
487
|
+
const fileList = Object.entries(fileContents)
|
|
488
|
+
.map(([path, content]) => \`--- \${path} ---\\n\${content}\`).join("\\n\\n");
|
|
489
|
+
|
|
490
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
491
|
+
method: "POST",
|
|
492
|
+
headers: {
|
|
493
|
+
"Content-Type": "application/json",
|
|
494
|
+
"x-api-key": ANTHROPIC_API_KEY,
|
|
495
|
+
"anthropic-version": "2023-06-01",
|
|
496
|
+
},
|
|
497
|
+
body: JSON.stringify({
|
|
498
|
+
model: "claude-sonnet-4-20250514",
|
|
499
|
+
max_tokens: 16384,
|
|
500
|
+
system: \`You are Anteater, an AI coding agent. You modify web application source files based on user requests.
|
|
501
|
+
RULES: Make minimal, focused changes. Only modify files that need to change. Preserve existing code style.
|
|
502
|
+
Never modify environment files, API routes, or configuration.
|
|
503
|
+
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.
|
|
504
|
+
OUTPUT FORMAT: Return a JSON array of objects with "path" and "content". Return ONLY valid JSON, no markdown fences.
|
|
505
|
+
If no changes are needed, return an empty array: []\`,
|
|
506
|
+
messages: [{ role: "user", content: \`Files:\\n\\n\${fileList}\\n\\nRequest: \${prompt}\` }],
|
|
507
|
+
}),
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
if (!res.ok) throw new Error(\`Anthropic API error \${res.status}: \${await res.text()}\`);
|
|
511
|
+
const data = await res.json();
|
|
512
|
+
if (data.stop_reason === "max_tokens") throw new Error("Response truncated — max_tokens exceeded");
|
|
513
|
+
return JSON.parse(data.content?.[0]?.text || "[]");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function main() {
|
|
517
|
+
console.log(\`Anteater agent: "\${args.prompt}"\`);
|
|
518
|
+
const paths = await collectFiles();
|
|
519
|
+
console.log(\`Found \${paths.length} editable files\`);
|
|
520
|
+
if (!paths.length) { console.log("No files matched."); process.exit(0); }
|
|
521
|
+
|
|
522
|
+
const contents = await readFiles(paths);
|
|
523
|
+
console.log("Calling Claude...");
|
|
524
|
+
const changes = await callClaude(args.prompt, contents);
|
|
525
|
+
|
|
526
|
+
if (!changes?.length) { console.log("No changes needed."); process.exit(0); }
|
|
527
|
+
|
|
528
|
+
// Validate returned paths match input files
|
|
529
|
+
const validPathSet = new Set(Object.keys(contents));
|
|
530
|
+
const validated = [];
|
|
531
|
+
for (const change of changes) {
|
|
532
|
+
if (validPathSet.has(change.path)) {
|
|
533
|
+
validated.push(change);
|
|
534
|
+
} else {
|
|
535
|
+
console.warn(\` Rejected: \${change.path} (not in allowed input files)\`);
|
|
536
|
+
const basename = change.path.split("/").pop();
|
|
537
|
+
const match = [...validPathSet].find((p) => p.endsWith("/" + basename));
|
|
538
|
+
if (match) {
|
|
539
|
+
console.log(\` Corrected: \${change.path} → \${match}\`);
|
|
540
|
+
validated.push({ path: match, content: change.content });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (!validated.length) { console.log("No valid changes after path validation."); process.exit(0); }
|
|
546
|
+
console.log(\`Modifying \${validated.length} file(s)\`);
|
|
547
|
+
for (const { path, content } of validated) {
|
|
548
|
+
await mkdir(dirname(path), { recursive: true });
|
|
549
|
+
await writeFile(path, content, "utf-8");
|
|
550
|
+
console.log(\` Updated: \${path}\`);
|
|
551
|
+
}
|
|
552
|
+
console.log("Done!");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
main().catch((err) => { console.error("Agent failed:", err); process.exit(1); });
|
|
556
|
+
`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Patch the layout file to include AnteaterBar.
|
|
561
|
+
*/
|
|
562
|
+
export async function patchLayout(layoutPath, cwd) {
|
|
563
|
+
const fullPath = join(cwd, layoutPath);
|
|
564
|
+
let content = await readFile(fullPath, "utf-8");
|
|
565
|
+
|
|
566
|
+
// Don't patch if already has AnteaterBar
|
|
567
|
+
if (content.includes("AnteaterBar")) {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Add import at the top (after last import line)
|
|
572
|
+
const importLine = `import { AnteaterBar } from "@anteater/next";\n`;
|
|
573
|
+
const lastImportIdx = content.lastIndexOf("import ");
|
|
574
|
+
if (lastImportIdx !== -1) {
|
|
575
|
+
const endOfLine = content.indexOf("\n", lastImportIdx);
|
|
576
|
+
content = content.slice(0, endOfLine + 1) + importLine + content.slice(endOfLine + 1);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Add <AnteaterBar /> before closing </body>
|
|
580
|
+
content = content.replace(
|
|
581
|
+
/([ \t]*)<\/body>/,
|
|
582
|
+
`$1 <AnteaterBar />\n$1</body>`
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
await writeFile(fullPath, content, "utf-8");
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Write all scaffolded files.
|
|
591
|
+
*/
|
|
592
|
+
export async function scaffoldFiles(cwd, options) {
|
|
593
|
+
const results = [];
|
|
594
|
+
|
|
595
|
+
// anteater.config
|
|
596
|
+
const config = generateConfig(options);
|
|
597
|
+
if (await writeIfNotExists(join(cwd, config.filename), config.content)) {
|
|
598
|
+
results.push(config.filename);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// API route
|
|
602
|
+
const route = generateApiRoute(options);
|
|
603
|
+
const routeDir = options.isAppRouter ? "app/api/anteater" : "pages/api/anteater";
|
|
604
|
+
const routePath = join(cwd, routeDir, route.filename);
|
|
605
|
+
if (await writeIfNotExists(routePath, route.content)) {
|
|
606
|
+
results.push(join(routeDir, route.filename));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// GitHub Action workflow
|
|
610
|
+
const workflowPath = join(cwd, ".github/workflows/anteater.yml");
|
|
611
|
+
if (await writeIfNotExists(workflowPath, generateWorkflow(options))) {
|
|
612
|
+
results.push(".github/workflows/anteater.yml");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Claude Code agent settings
|
|
616
|
+
if (options.model && options.permissionsMode) {
|
|
617
|
+
const settingsPath = join(cwd, ".claude/settings.local.json");
|
|
618
|
+
if (await writeIfNotExists(settingsPath, generateClaudeSettings(options))) {
|
|
619
|
+
results.push(".claude/settings.local.json");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Patch layout
|
|
624
|
+
if (options.layoutFile) {
|
|
625
|
+
if (await patchLayout(options.layoutFile, cwd)) {
|
|
626
|
+
results.push(`${options.layoutFile} (patched)`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return results;
|
|
631
|
+
}
|
package/lib/secrets.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets management — sets GitHub Actions secrets and Vercel env vars.
|
|
3
|
+
*/
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if a CLI tool is available.
|
|
9
|
+
*/
|
|
10
|
+
export function hasCommand(cmd) {
|
|
11
|
+
try {
|
|
12
|
+
execSync(`${cmd} --version`, { stdio: "ignore" });
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate a random secret.
|
|
21
|
+
*/
|
|
22
|
+
export function generateSecret() {
|
|
23
|
+
return `ak_${crypto.randomBytes(16).toString("hex")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate an Anthropic API key by making a lightweight API call.
|
|
28
|
+
*/
|
|
29
|
+
export async function validateAnthropicKey(key) {
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
"x-api-key": key,
|
|
36
|
+
"anthropic-version": "2023-06-01",
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
model: "claude-sonnet-4-20250514",
|
|
40
|
+
max_tokens: 1,
|
|
41
|
+
messages: [{ role: "user", content: "hi" }],
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
// 200 = valid key, 400 = valid key (bad request is fine), 401 = invalid
|
|
45
|
+
return res.status !== 401 && res.status !== 403;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if a GitHub token has the scopes needed to dispatch workflows.
|
|
53
|
+
* Returns { ok, scopes, missing } — missing lists what's absent.
|
|
54
|
+
*/
|
|
55
|
+
export async function validateGitHubToken(token, repo) {
|
|
56
|
+
try {
|
|
57
|
+
// Check token scopes via the API
|
|
58
|
+
const res = await fetch("https://api.github.com/", {
|
|
59
|
+
headers: {
|
|
60
|
+
Authorization: `Bearer ${token}`,
|
|
61
|
+
Accept: "application/vnd.github+json",
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const scopeHeader = res.headers.get("x-oauth-scopes") || "";
|
|
65
|
+
const scopes = scopeHeader.split(",").map((s) => s.trim()).filter(Boolean);
|
|
66
|
+
|
|
67
|
+
// Fine-grained PATs don't return x-oauth-scopes — test dispatch directly
|
|
68
|
+
if (!scopes.length) {
|
|
69
|
+
const dispatchOk = await testDispatchAccess(token, repo);
|
|
70
|
+
return { ok: dispatchOk, scopes: ["fine-grained"], missing: dispatchOk ? [] : ["actions:write"] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const missing = [];
|
|
74
|
+
if (!scopes.includes("repo")) missing.push("repo");
|
|
75
|
+
if (!scopes.includes("workflow")) missing.push("workflow");
|
|
76
|
+
return { ok: missing.length === 0, scopes, missing };
|
|
77
|
+
} catch {
|
|
78
|
+
return { ok: false, scopes: [], missing: ["unknown"] };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Test if a token can actually dispatch the anteater workflow.
|
|
84
|
+
* Uses a dry-run approach: checks if the workflow exists and is accessible.
|
|
85
|
+
*/
|
|
86
|
+
async function testDispatchAccess(token, repo) {
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(
|
|
89
|
+
`https://api.github.com/repos/${repo}/actions/workflows`,
|
|
90
|
+
{
|
|
91
|
+
headers: {
|
|
92
|
+
Authorization: `Bearer ${token}`,
|
|
93
|
+
Accept: "application/vnd.github+json",
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
return res.ok;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Set a GitHub Actions secret using the gh CLI.
|
|
105
|
+
*/
|
|
106
|
+
export function setGitHubSecret(repo, name, value) {
|
|
107
|
+
if (!hasCommand("gh")) {
|
|
108
|
+
throw new Error("GitHub CLI (gh) is not installed. Install it: https://cli.github.com");
|
|
109
|
+
}
|
|
110
|
+
execSync(`gh secret set ${name} --repo ${repo} --body -`, {
|
|
111
|
+
input: value,
|
|
112
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Set a Vercel environment variable using the vercel CLI.
|
|
118
|
+
* Returns false if vercel CLI is not available.
|
|
119
|
+
*/
|
|
120
|
+
export function setVercelEnv(name, value, environments = ["production", "preview", "development"]) {
|
|
121
|
+
if (!hasCommand("vercel")) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
for (const env of environments) {
|
|
125
|
+
try {
|
|
126
|
+
execSync(`vercel env add ${name} ${env} --force`, {
|
|
127
|
+
input: value,
|
|
128
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
129
|
+
});
|
|
130
|
+
} catch {
|
|
131
|
+
// May fail if not linked — that's okay
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Write secrets to a local .env.local file (fallback).
|
|
140
|
+
*/
|
|
141
|
+
export async function writeEnvLocal(cwd, secrets) {
|
|
142
|
+
const { writeFile, readFile } = await import("node:fs/promises");
|
|
143
|
+
const { join } = await import("node:path");
|
|
144
|
+
const envPath = join(cwd, ".env.local");
|
|
145
|
+
|
|
146
|
+
let existing = "";
|
|
147
|
+
try {
|
|
148
|
+
existing = await readFile(envPath, "utf-8");
|
|
149
|
+
} catch {
|
|
150
|
+
// File doesn't exist yet
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const lines = [];
|
|
154
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
155
|
+
// Don't duplicate existing keys
|
|
156
|
+
if (!existing.includes(`${key}=`)) {
|
|
157
|
+
lines.push(`${key}=${value}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (lines.length > 0) {
|
|
162
|
+
const newContent = existing + (existing.endsWith("\n") || !existing ? "" : "\n") + lines.join("\n") + "\n";
|
|
163
|
+
await writeFile(envPath, newContent, "utf-8");
|
|
164
|
+
}
|
|
165
|
+
}
|
package/lib/setup.mjs
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* anteater setup — Interactive CLI to install and configure Anteater.
|
|
3
|
+
*
|
|
4
|
+
* Core logic extracted from bin/setup-anteater.mjs so it can be
|
|
5
|
+
* imported by tests without hitting the shebang line.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import {
|
|
10
|
+
bold, dim, green, red, yellow, cyan,
|
|
11
|
+
ok, fail, warn, info, heading, blank,
|
|
12
|
+
ask, confirm, select, spinner,
|
|
13
|
+
} from "./ui.mjs";
|
|
14
|
+
import { detectProject } from "./detect.mjs";
|
|
15
|
+
import { scaffoldFiles } from "./scaffold.mjs";
|
|
16
|
+
import {
|
|
17
|
+
validateAnthropicKey, validateGitHubToken, setGitHubSecret, setVercelEnv,
|
|
18
|
+
writeEnvLocal, hasCommand,
|
|
19
|
+
} from "./secrets.mjs";
|
|
20
|
+
|
|
21
|
+
const cwd = process.cwd();
|
|
22
|
+
|
|
23
|
+
export async function main() {
|
|
24
|
+
console.log();
|
|
25
|
+
console.log(` ${bold("\u{1F41C} Anteater Setup")}`);
|
|
26
|
+
console.log(` ${"\u2500".repeat(17)}`);
|
|
27
|
+
blank();
|
|
28
|
+
|
|
29
|
+
// ─── Preflight checks ──────────────────────────────────────
|
|
30
|
+
heading("Preflight");
|
|
31
|
+
|
|
32
|
+
if (!hasCommand("gh")) {
|
|
33
|
+
fail("GitHub CLI (gh) is required. Install it: https://cli.github.com");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
ok("GitHub CLI installed");
|
|
37
|
+
|
|
38
|
+
if (!hasCommand("vercel")) {
|
|
39
|
+
fail("Vercel CLI is required. Install it: npm i -g vercel");
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
ok("Vercel CLI installed");
|
|
43
|
+
|
|
44
|
+
// ─── Detect project ─────────────────────────────────────────
|
|
45
|
+
const project = await detectProject(cwd);
|
|
46
|
+
|
|
47
|
+
if (!project.isNextJs) {
|
|
48
|
+
fail("No Next.js project found. Run this from your Next.js project root.");
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
ok(`Next.js ${project.nextVersion ?? ""} ${project.isAppRouter ? "(App Router)" : "(Pages Router)"}`);
|
|
52
|
+
|
|
53
|
+
if (!project.hasGit || !project.gitRemote) {
|
|
54
|
+
fail("No GitHub remote found. Run: git remote add origin <url>");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
ok(`Repo: ${project.gitRemote}`);
|
|
58
|
+
ok(`Branch: ${project.defaultBranch || "main"}`);
|
|
59
|
+
ok(`Package manager: ${project.packageManager}`);
|
|
60
|
+
blank();
|
|
61
|
+
|
|
62
|
+
// ─── Step 1: Anthropic API key ──────────────────────────────
|
|
63
|
+
heading("Step 1 of 4 \u2014 AI Provider");
|
|
64
|
+
info(`Get a key at ${cyan("https://console.anthropic.com/keys")}`);
|
|
65
|
+
blank();
|
|
66
|
+
|
|
67
|
+
let anthropicKey;
|
|
68
|
+
while (true) {
|
|
69
|
+
anthropicKey = await ask("Anthropic API key:", { mask: true });
|
|
70
|
+
if (!anthropicKey) { warn("Required."); continue; }
|
|
71
|
+
const valid = await spinner("Validating", () => validateAnthropicKey(anthropicKey));
|
|
72
|
+
if (valid) break;
|
|
73
|
+
fail("Invalid key. Check that it starts with sk-ant- and try again.");
|
|
74
|
+
}
|
|
75
|
+
blank();
|
|
76
|
+
|
|
77
|
+
// ─── Step 2: GitHub access ──────────────────────────────────
|
|
78
|
+
heading("Step 2 of 4 \u2014 GitHub Access");
|
|
79
|
+
|
|
80
|
+
let githubToken;
|
|
81
|
+
try {
|
|
82
|
+
githubToken = execSync("gh auth token", { encoding: "utf-8" }).trim();
|
|
83
|
+
} catch {
|
|
84
|
+
fail("GitHub CLI not authenticated. Run: gh auth login");
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// OAuth tokens (gho_*) expire in ~8 hours \u2014 not suitable for Vercel env
|
|
89
|
+
if (githubToken.startsWith("gho_")) {
|
|
90
|
+
warn("Your GitHub CLI token is a short-lived OAuth token (expires in ~8 hours).");
|
|
91
|
+
info("Anteater needs a long-lived Personal Access Token (PAT) for the deployed API route.");
|
|
92
|
+
info(`Create one at ${cyan("https://github.com/settings/tokens")} with ${bold("repo")} + ${bold("workflow")} scopes.`);
|
|
93
|
+
blank();
|
|
94
|
+
githubToken = await ask("Paste your GitHub PAT (ghp_... or github_pat_...):");
|
|
95
|
+
if (!githubToken) {
|
|
96
|
+
fail("A GitHub PAT is required.");
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
ok("Using token from GitHub CLI");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const check = await spinner("Checking permissions", () =>
|
|
104
|
+
validateGitHubToken(githubToken, project.gitRemote)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (!check.ok && check.missing.length > 0 && !check.missing.includes("unknown")) {
|
|
108
|
+
if (githubToken.startsWith("ghp_") || githubToken.startsWith("github_pat_")) {
|
|
109
|
+
fail("Token is missing required scopes: " + check.missing.join(", "));
|
|
110
|
+
info("Create a new PAT with repo + workflow scopes.");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
info("Upgrading token scopes...");
|
|
114
|
+
try {
|
|
115
|
+
execSync(`gh auth refresh --scopes ${check.missing.join(",")}`, { stdio: "inherit" });
|
|
116
|
+
githubToken = execSync("gh auth token", { encoding: "utf-8" }).trim();
|
|
117
|
+
ok("Token scopes updated");
|
|
118
|
+
} catch {
|
|
119
|
+
fail("Could not upgrade token. Run: gh auth refresh --scopes repo,workflow");
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
} else if (check.ok) {
|
|
123
|
+
ok("Token has required permissions");
|
|
124
|
+
}
|
|
125
|
+
blank();
|
|
126
|
+
|
|
127
|
+
// ─── Step 3: Configure paths ────────────────────────────────
|
|
128
|
+
heading("Step 3 of 4 \u2014 Editable Paths");
|
|
129
|
+
|
|
130
|
+
const defaultAllowed = [];
|
|
131
|
+
const defaultBlocked = ["lib/auth/**", "lib/billing/**", ".env*"];
|
|
132
|
+
|
|
133
|
+
if (project.isAppRouter) {
|
|
134
|
+
defaultAllowed.push("app/**", "components/**", "styles/**");
|
|
135
|
+
defaultBlocked.push("app/api/**");
|
|
136
|
+
} else {
|
|
137
|
+
defaultAllowed.push("pages/**", "components/**", "styles/**");
|
|
138
|
+
defaultBlocked.push("pages/api/**");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log(` ${green("Allowed:")} ${defaultAllowed.join(", ")}`);
|
|
142
|
+
console.log(` ${red("Blocked:")} ${defaultBlocked.join(", ")}`);
|
|
143
|
+
blank();
|
|
144
|
+
|
|
145
|
+
const useDefaults = await confirm("Use these defaults?");
|
|
146
|
+
let allowedGlobs = defaultAllowed;
|
|
147
|
+
let blockedGlobs = defaultBlocked;
|
|
148
|
+
|
|
149
|
+
if (!useDefaults) {
|
|
150
|
+
const customAllowed = await ask("Allowed globs (comma-separated):");
|
|
151
|
+
const customBlocked = await ask("Blocked globs (comma-separated):");
|
|
152
|
+
if (customAllowed) allowedGlobs = customAllowed.split(",").map((s) => s.trim());
|
|
153
|
+
if (customBlocked) blockedGlobs = customBlocked.split(",").map((s) => s.trim());
|
|
154
|
+
}
|
|
155
|
+
blank();
|
|
156
|
+
|
|
157
|
+
// ─── Step 4: Agent configuration ─────────────────────────────
|
|
158
|
+
heading("Step 4 of 4 \u2014 Agent Configuration");
|
|
159
|
+
|
|
160
|
+
const model = await select("Select AI model:", [
|
|
161
|
+
{ label: "Sonnet (recommended)", hint: "fast, cost-effective, great for most changes", value: "sonnet" },
|
|
162
|
+
{ label: "Opus", hint: "most capable, higher cost", value: "opus" },
|
|
163
|
+
{ label: "Opus 1M", hint: "Opus with extended context (1M tokens)", value: "opus[1m]" },
|
|
164
|
+
{ label: "Haiku", hint: "fastest, lowest cost, best for simple changes", value: "haiku" },
|
|
165
|
+
]);
|
|
166
|
+
ok(`Model: ${model}`);
|
|
167
|
+
blank();
|
|
168
|
+
|
|
169
|
+
let permissionsMode = await select("Select agent permissions mode:", [
|
|
170
|
+
{ label: "Sandboxed (recommended)", hint: "full local access, no internet or external services", value: "sandboxed" },
|
|
171
|
+
{ label: "Unrestricted", hint: "full access including web, GitHub CLI, Vercel, and all MCP tools", value: "unrestricted" },
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
if (permissionsMode === "unrestricted") {
|
|
175
|
+
blank();
|
|
176
|
+
warn("Unrestricted mode grants the AI agent full access to:");
|
|
177
|
+
info(" - Internet (web fetches, searches, curl)");
|
|
178
|
+
info(" - GitHub CLI (push, PR creation, issue management)");
|
|
179
|
+
info(" - Vercel CLI (deployments, env vars)");
|
|
180
|
+
info(" - All MCP tools (browser automation, etc.)");
|
|
181
|
+
info(" - File deletion and system commands");
|
|
182
|
+
blank();
|
|
183
|
+
warn("The agent will run with bypassPermissions \u2014 no confirmation prompts.");
|
|
184
|
+
warn("Only use this if you trust the prompts your users will submit.");
|
|
185
|
+
blank();
|
|
186
|
+
const confirmed = await confirm("Confirm unrestricted mode?", false);
|
|
187
|
+
if (!confirmed) {
|
|
188
|
+
permissionsMode = "sandboxed";
|
|
189
|
+
ok("Falling back to Sandboxed mode");
|
|
190
|
+
} else {
|
|
191
|
+
ok("Unrestricted mode confirmed");
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
ok("Sandboxed mode \u2014 agent cannot access internet or external services");
|
|
195
|
+
}
|
|
196
|
+
blank();
|
|
197
|
+
|
|
198
|
+
// ─── Install & scaffold ─────────────────────────────────────
|
|
199
|
+
heading("Installing");
|
|
200
|
+
|
|
201
|
+
const installCmd = {
|
|
202
|
+
pnpm: "pnpm add @anteater/next",
|
|
203
|
+
yarn: "yarn add @anteater/next",
|
|
204
|
+
npm: "npm install @anteater/next",
|
|
205
|
+
}[project.packageManager];
|
|
206
|
+
|
|
207
|
+
await spinner("Installing @anteater/next", () => {
|
|
208
|
+
execSync(installCmd, { cwd, stdio: "ignore" });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const productionBranch = project.defaultBranch || "main";
|
|
212
|
+
const scaffolded = await spinner("Creating files", () =>
|
|
213
|
+
scaffoldFiles(cwd, {
|
|
214
|
+
repo: project.gitRemote,
|
|
215
|
+
allowedGlobs,
|
|
216
|
+
blockedGlobs,
|
|
217
|
+
autoMerge: true,
|
|
218
|
+
productionBranch,
|
|
219
|
+
isTypeScript: project.isTypeScript,
|
|
220
|
+
isAppRouter: project.isAppRouter,
|
|
221
|
+
layoutFile: project.layoutFile,
|
|
222
|
+
model,
|
|
223
|
+
permissionsMode,
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
for (const f of scaffolded) ok(`Created ${f}`);
|
|
228
|
+
|
|
229
|
+
// ─── Set secrets ────────────────────────────────────────────
|
|
230
|
+
heading("Configuring secrets");
|
|
231
|
+
|
|
232
|
+
// GitHub Actions secret
|
|
233
|
+
try {
|
|
234
|
+
await spinner("Setting ANTHROPIC_API_KEY in GitHub secrets", () => {
|
|
235
|
+
setGitHubSecret(project.gitRemote, "ANTHROPIC_API_KEY", anthropicKey);
|
|
236
|
+
});
|
|
237
|
+
} catch (err) {
|
|
238
|
+
warn(`Could not set secret: ${err.message}`);
|
|
239
|
+
info("Set manually: gh secret set ANTHROPIC_API_KEY --repo " + project.gitRemote);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// .env.local for local dev (only GITHUB_TOKEN needed)
|
|
243
|
+
await spinner("Writing .env.local", () =>
|
|
244
|
+
writeEnvLocal(cwd, { GITHUB_TOKEN: githubToken })
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Vercel: only GITHUB_TOKEN needed (repo auto-detected, deploy detection automatic)
|
|
248
|
+
await spinner("Setting GITHUB_TOKEN in Vercel", () => {
|
|
249
|
+
setVercelEnv("GITHUB_TOKEN", githubToken);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ─── Push workflow ──────────────────────────────────────────
|
|
253
|
+
if (scaffolded.some((f) => f.includes("anteater.yml"))) {
|
|
254
|
+
await spinner("Pushing workflow to GitHub", () => {
|
|
255
|
+
execSync(`git add .github/workflows/anteater.yml`, { cwd, stdio: "ignore" });
|
|
256
|
+
execSync(`git commit -m "chore: add Anteater workflow"`, { cwd, stdio: "ignore" });
|
|
257
|
+
execSync(`git push origin ${productionBranch}`, { cwd, stdio: "ignore" });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Verify
|
|
261
|
+
const activated = await spinner("Verifying workflow", async () => {
|
|
262
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
263
|
+
try {
|
|
264
|
+
const res = await fetch(
|
|
265
|
+
`https://api.github.com/repos/${project.gitRemote}/actions/workflows`,
|
|
266
|
+
{ headers: { Authorization: `Bearer ${githubToken}`, Accept: "application/vnd.github+json" } }
|
|
267
|
+
);
|
|
268
|
+
const data = await res.json();
|
|
269
|
+
return data.workflows?.some((w) => w.path === ".github/workflows/anteater.yml" && w.state === "active");
|
|
270
|
+
} catch { return false; }
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (activated) ok("Workflow active");
|
|
274
|
+
else warn(`Check: ${cyan(`https://github.com/${project.gitRemote}/actions`)}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─── Test ───────────────────────────────────────────────────
|
|
278
|
+
const dispatchOk = await spinner("Running test dispatch", async () => {
|
|
279
|
+
try {
|
|
280
|
+
const res = await fetch(
|
|
281
|
+
`https://api.github.com/repos/${project.gitRemote}/actions/workflows/anteater.yml/dispatches`,
|
|
282
|
+
{
|
|
283
|
+
method: "POST",
|
|
284
|
+
headers: {
|
|
285
|
+
Authorization: `Bearer ${githubToken}`,
|
|
286
|
+
Accept: "application/vnd.github+json",
|
|
287
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
288
|
+
},
|
|
289
|
+
body: JSON.stringify({
|
|
290
|
+
ref: productionBranch,
|
|
291
|
+
inputs: {
|
|
292
|
+
requestId: "setup-test",
|
|
293
|
+
prompt: "setup verification \u2014 no changes expected",
|
|
294
|
+
mode: "prod",
|
|
295
|
+
branch: "anteater/setup-test",
|
|
296
|
+
baseBranch: productionBranch,
|
|
297
|
+
autoMerge: "false",
|
|
298
|
+
},
|
|
299
|
+
}),
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
return res.status === 204;
|
|
303
|
+
} catch { return false; }
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (dispatchOk) ok("Pipeline is working");
|
|
307
|
+
else warn("Test dispatch failed \u2014 check GitHub Actions");
|
|
308
|
+
|
|
309
|
+
// ─── Done! ──────────────────────────────────────────────────
|
|
310
|
+
blank();
|
|
311
|
+
console.log(` ${bold(green("\u{1F41C} Anteater is ready."))}`);
|
|
312
|
+
blank();
|
|
313
|
+
info(`Deploy your app and look for the "${green("Edit this page")}" button.`);
|
|
314
|
+
info("Users can modify your app by typing changes in the Anteater bar.");
|
|
315
|
+
blank();
|
|
316
|
+
}
|
package/lib/ui.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal UI helpers — zero dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import * as readline from "node:readline";
|
|
5
|
+
|
|
6
|
+
// ANSI colors
|
|
7
|
+
const esc = (code) => `\x1b[${code}m`;
|
|
8
|
+
export const bold = (s) => `${esc(1)}${s}${esc(22)}`;
|
|
9
|
+
export const dim = (s) => `${esc(2)}${s}${esc(22)}`;
|
|
10
|
+
export const green = (s) => `${esc(32)}${s}${esc(39)}`;
|
|
11
|
+
export const red = (s) => `${esc(31)}${s}${esc(39)}`;
|
|
12
|
+
export const yellow = (s) => `${esc(33)}${s}${esc(39)}`;
|
|
13
|
+
export const cyan = (s) => `${esc(36)}${s}${esc(39)}`;
|
|
14
|
+
|
|
15
|
+
export const ok = (msg) => console.log(` ${green("✓")} ${msg}`);
|
|
16
|
+
export const fail = (msg) => console.log(` ${red("✗")} ${msg}`);
|
|
17
|
+
export const warn = (msg) => console.log(` ${yellow("!")} ${msg}`);
|
|
18
|
+
export const info = (msg) => console.log(` ${dim(msg)}`);
|
|
19
|
+
export const heading = (msg) => console.log(`\n ${bold(msg)}\n ${"─".repeat(msg.length)}`);
|
|
20
|
+
export const blank = () => console.log();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Prompt the user for text input.
|
|
24
|
+
*/
|
|
25
|
+
export function ask(question, { mask = false } = {}) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const rl = readline.createInterface({
|
|
28
|
+
input: process.stdin,
|
|
29
|
+
output: process.stdout,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// If masking, mute output and write dots
|
|
33
|
+
if (mask) {
|
|
34
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
35
|
+
process.stdout.write = (chunk, encoding, cb) => {
|
|
36
|
+
// Let the question prompt through, mask everything after
|
|
37
|
+
if (typeof chunk === "string" && chunk.includes(question)) {
|
|
38
|
+
return origWrite(chunk, encoding, cb);
|
|
39
|
+
}
|
|
40
|
+
// Replace characters with bullets
|
|
41
|
+
const masked = typeof chunk === "string" ? chunk.replace(/[^\r\n]/g, "•") : chunk;
|
|
42
|
+
return origWrite(masked, encoding, cb);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
rl.on("close", () => {
|
|
46
|
+
process.stdout.write = origWrite;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
rl.question(` ${cyan("?")} ${question} `, (answer) => {
|
|
51
|
+
rl.close();
|
|
52
|
+
resolve(answer.trim());
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Prompt yes/no (defaults to yes).
|
|
59
|
+
*/
|
|
60
|
+
export async function confirm(question, defaultYes = true) {
|
|
61
|
+
const hint = defaultYes ? "Y/n" : "y/N";
|
|
62
|
+
const answer = await ask(`${question} ${dim(`(${hint})`)}`);
|
|
63
|
+
if (!answer) return defaultYes;
|
|
64
|
+
return answer.toLowerCase().startsWith("y");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Prompt user to select from a list.
|
|
69
|
+
*/
|
|
70
|
+
export async function select(question, options) {
|
|
71
|
+
console.log(` ${cyan("?")} ${question}`);
|
|
72
|
+
for (let i = 0; i < options.length; i++) {
|
|
73
|
+
const marker = i === 0 ? green("❯") : " ";
|
|
74
|
+
console.log(` ${marker} ${options[i].label}${options[i].hint ? dim(` — ${options[i].hint}`) : ""}`);
|
|
75
|
+
}
|
|
76
|
+
const answer = await ask(`Enter choice (1-${options.length}):`, {});
|
|
77
|
+
const idx = parseInt(answer, 10) - 1;
|
|
78
|
+
if (idx >= 0 && idx < options.length) return options[idx].value;
|
|
79
|
+
return options[0].value; // default to first
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Show a spinner while an async function runs.
|
|
84
|
+
*/
|
|
85
|
+
export async function spinner(msg, fn) {
|
|
86
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
87
|
+
let i = 0;
|
|
88
|
+
const interval = setInterval(() => {
|
|
89
|
+
process.stdout.write(`\r ${green(frames[i++ % frames.length])} ${msg}`);
|
|
90
|
+
}, 80);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const result = await fn();
|
|
94
|
+
clearInterval(interval);
|
|
95
|
+
process.stdout.write(`\r ${green("✓")} ${msg}\n`);
|
|
96
|
+
return result;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
clearInterval(interval);
|
|
99
|
+
process.stdout.write(`\r ${red("✗")} ${msg}\n`);
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "next-anteater",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-powered live editing for your Next.js app",
|
|
5
|
+
"bin": {
|
|
6
|
+
"anteater": "./bin/setup-anteater.mjs"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"files": ["bin/", "lib/"],
|
|
10
|
+
"keywords": ["anteater", "ai", "nextjs", "github-actions", "vercel"],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
}
|
|
15
|
+
}
|