@nairon-ai/aegis 0.2.0 → 0.4.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.
@@ -57,9 +57,9 @@ This directory configures Aegis for this repository.
57
57
  ## First Run
58
58
 
59
59
  \`\`\`bash
60
- npx @nairon-ai/aegis setup
61
- npx @nairon-ai/aegis pickup --dry-run
62
- npx @nairon-ai/aegis deploy
60
+ npx --yes @nairon-ai/aegis@latest setup
61
+ npx --yes @nairon-ai/aegis@latest pickup --dry-run
62
+ npx --yes @nairon-ai/aegis@latest deploy
63
63
  \`\`\`
64
64
 
65
65
  For the first test, use:
@@ -86,6 +86,14 @@ Telegram: https://YOUR_WORKER.workers.dev/webhook/telegram
86
86
  \`\`\`
87
87
 
88
88
  Keep \`.aegis/.env\` out of git.
89
+
90
+ ## Disconnect
91
+
92
+ \`\`\`bash
93
+ npx --yes @nairon-ai/aegis@latest disconnect
94
+ \`\`\`
95
+
96
+ This prints the checklist for removing the Worker, GitHub access, webhooks, and local config.
89
97
  `;
90
98
 
91
99
  export const initCommand = defineCommand({
@@ -102,11 +110,12 @@ export function runInit(): void {
102
110
  writeIfMissing(resolve(PROJECT_AEGIS_DIR, "README.md"), README);
103
111
  consola.success("Created .aegis/");
104
112
  consola.log("\nNext:");
105
- consola.log(" 1. npx @nairon-ai/aegis setup");
113
+ consola.log(" 1. npx --yes @nairon-ai/aegis@latest setup");
106
114
  consola.log(" 2. create GitHub labels: bug, ready to implement");
107
115
  consola.log(" 3. create a tiny test bug issue");
108
- consola.log(" 4. npx @nairon-ai/aegis pickup --dry-run");
109
- consola.log(" 5. npx @nairon-ai/aegis deploy");
116
+ consola.log(" 4. npx --yes @nairon-ai/aegis@latest pickup --dry-run");
117
+ consola.log(" 5. npx --yes @nairon-ai/aegis@latest deploy");
118
+ consola.log(" Later: npx --yes @nairon-ai/aegis@latest disconnect");
110
119
  consola.log("");
111
120
  }
112
121
 
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import { defineCommand } from "citty";
4
4
  import { consola } from "consola";
5
5
  import type { AegisCliConfig } from "../../shared/types.js";
6
+ import { runGitHubAppFlow } from "../github-app-flow.js";
6
7
  import { resolveEnvFileForWrite } from "../paths.js";
7
8
  import { detectSetupState, printState, saveConfig } from "../state.js";
8
9
 
@@ -32,63 +33,101 @@ export async function runSetupWizard(): Promise<void> {
32
33
  consola.log("\nNext:");
33
34
  consola.log(" 1. Create labels in the monitored repo: bug, ready to implement");
34
35
  consola.log(" 2. Create a test GitHub issue with both labels");
35
- consola.log(" 3. npx @nairon-ai/aegis pickup --dry-run");
36
- consola.log(" 4. npx @nairon-ai/aegis deploy");
36
+ consola.log(" 3. npx --yes @nairon-ai/aegis@latest pickup --dry-run");
37
+ consola.log(" 4. npx --yes @nairon-ai/aegis@latest deploy");
37
38
  consola.log(" 5. Add GitHub/Linear/Telegram webhook URLs after deploy");
39
+ consola.log(" Later: npx --yes @nairon-ai/aegis@latest disconnect");
38
40
  consola.log("");
39
41
  }
40
42
 
41
43
  async function setupRepo(config: AegisCliConfig): Promise<void> {
42
- config.monitoredRepo = await promptText(
43
- "GitHub repo to monitor (owner/repo):",
44
- config.monitoredRepo,
44
+ printStep("Step 1: choose the GitHub repo Aegis should fix", [
45
+ "This is your product repo, not the Aegis repo.",
46
+ "Paste the GitHub URL from your browser, or type owner/repo.",
47
+ "Example: https://github.com/KeyLead-Team/keylead",
48
+ ]);
49
+ config.monitoredRepo = normalizeGitHubRepo(
50
+ await promptText("Repo to monitor:", config.monitoredRepo),
45
51
  );
46
- config.baseBranch = await promptText("Base branch:", config.baseBranch ?? "main");
52
+
53
+ printStep("Step 2: choose the base branch", [
54
+ "This is the branch Aegis will open PRs against.",
55
+ "Most repos use main. If your repo uses master, develop, or staging, enter that.",
56
+ ]);
57
+ config.baseBranch = await promptText("Base branch:", config.baseBranch ?? detectCurrentBranch());
47
58
  }
48
59
 
49
60
  async function setupGitHub(config: AegisCliConfig): Promise<void> {
50
- consola.log("\nGitHub access");
51
- consola.info(
52
- "For the first test, a fine-grained token is fastest. For shared/team use, use a GitHub App.",
53
- );
61
+ printStep("Step 3: give Aegis GitHub access", [
62
+ "Aegis needs permission to read issues, create branches, push fixes, and open PRs.",
63
+ "If you are unsure, choose the one-click GitHub App. It opens GitHub in your browser and avoids manual tokens.",
64
+ "Use a GitHub token only if you already know how to create fine-grained tokens.",
65
+ ]);
54
66
  const method = (await consola.prompt("Auth method:", {
55
67
  type: "select",
56
68
  options: [
57
- { label: "GitHub App (recommended)", value: "app" },
58
- { label: "GitHub token (simpler local testing)", value: "token" },
69
+ { label: "One-click GitHub App (recommended)", value: "app-flow" },
70
+ { label: "GitHub token (quick local test)", value: "token" },
71
+ { label: "Paste existing GitHub App credentials", value: "app-manual" },
59
72
  ],
60
73
  })) as string;
61
74
 
62
75
  if (method === "token") {
63
- config.githubToken = await promptText(
64
- "GitHub token with contents/issues/PR read-write access:",
65
- config.githubToken,
66
- );
67
- } else {
68
- config.githubAppId = await promptText("GitHub App ID:", config.githubAppId);
69
- const keyPath = await promptText("Path to GitHub App private key .pem:", undefined);
70
- if (keyPath) {
71
- const resolved = resolve(keyPath.replace(/^~/, process.env.HOME ?? "~"));
72
- if (existsSync(resolved)) {
73
- config.githubAppPrivateKey = Buffer.from(readFileSync(resolved, "utf-8")).toString(
74
- "base64",
75
- );
76
- consola.success("Loaded private key");
77
- } else {
78
- consola.warn(`File not found: ${resolved}`);
79
- }
76
+ printStep("Create a fine-grained GitHub token", [
77
+ "Open: https://github.com/settings/personal-access-tokens/new",
78
+ "Token name: Aegis",
79
+ "Repository access: Only select repositories, then choose the repo from Step 1.",
80
+ "Repository permissions: Contents read/write, Issues read/write, Pull requests read/write.",
81
+ "Metadata stays read-only automatically.",
82
+ "Click Generate token, then paste the token here. It usually starts with github_pat_.",
83
+ "Keep it secret. Aegis saves it to .aegis/.env, which should not be committed.",
84
+ ]);
85
+ config.githubToken = await promptText("Paste GitHub token:", config.githubToken);
86
+ config.githubAppId = undefined;
87
+ config.githubAppPrivateKey = undefined;
88
+ config.githubInstallationId = undefined;
89
+ } else if (method === "app-flow") {
90
+ if (!config.monitoredRepo) {
91
+ consola.warn("Set the monitored repo first.");
92
+ return;
80
93
  }
81
- if (!config.githubAppPrivateKey) {
82
- config.githubAppPrivateKey = Buffer.from(
83
- await promptText("Paste GitHub App private key PEM:", config.githubAppPrivateKey),
84
- ).toString("base64");
94
+ printStep("Create the GitHub App in your browser", [
95
+ "Aegis will open a local setup page.",
96
+ "Click Create GitHub App.",
97
+ "Then click Install and choose Only select repositories.",
98
+ "Select the repo from Step 1, then approve the install.",
99
+ "When GitHub sends you back, this CLI saves the credentials automatically.",
100
+ ]);
101
+ try {
102
+ const app = await runGitHubAppFlow({
103
+ monitoredRepo: config.monitoredRepo,
104
+ workerUrl: config.workerUrl,
105
+ });
106
+ config.githubAppId = app.appId;
107
+ config.githubAppPrivateKey = Buffer.from(app.privateKey, "utf-8").toString("base64");
108
+ config.githubInstallationId = app.installationId;
109
+ config.githubWebhookSecret = app.webhookSecret;
110
+ config.githubToken = undefined;
111
+ consola.success("Saved GitHub App credentials from GitHub.");
112
+ } catch (error) {
113
+ consola.warn(error instanceof Error ? error.message : String(error));
114
+ consola.info("Falling back to manual GitHub App credentials.");
115
+ await setupGitHubAppManual(config);
85
116
  }
86
- config.githubInstallationId = await promptText(
87
- "GitHub App installation ID:",
88
- config.githubInstallationId,
89
- );
117
+ } else {
118
+ await setupGitHubAppManual(config);
119
+ }
120
+
121
+ if (config.githubWebhookSecret) {
122
+ consola.info("GitHub webhook secret is already configured.");
123
+ return;
90
124
  }
91
125
 
126
+ printStep("GitHub webhook secret", [
127
+ "This protects webhook calls from GitHub after you deploy.",
128
+ "Choose yes and Aegis will generate a random secret for you.",
129
+ "You do not need to remember it; it is saved in .aegis/.env.",
130
+ ]);
92
131
  const generateSecret = (await consola.prompt("Generate a GitHub webhook secret?", {
93
132
  type: "confirm",
94
133
  initial: !config.githubWebhookSecret,
@@ -101,14 +140,56 @@ async function setupGitHub(config: AegisCliConfig): Promise<void> {
101
140
  }
102
141
  }
103
142
 
143
+ async function setupGitHubAppManual(config: AegisCliConfig): Promise<void> {
144
+ printStep("Paste existing GitHub App credentials", [
145
+ "Use this only if you already created a GitHub App manually.",
146
+ "You need the App ID, the private key .pem file, and the installation ID.",
147
+ "If that sounds unfamiliar, cancel and rerun setup with the one-click GitHub App option.",
148
+ ]);
149
+ config.githubAppId = await promptText("GitHub App ID:", config.githubAppId);
150
+ const keyPath = await promptText("Path to GitHub App private key .pem:", undefined);
151
+ if (keyPath) {
152
+ const resolved = resolve(keyPath.replace(/^~/, process.env.HOME ?? "~"));
153
+ if (existsSync(resolved)) {
154
+ config.githubAppPrivateKey = Buffer.from(readFileSync(resolved, "utf-8")).toString("base64");
155
+ consola.success("Loaded private key");
156
+ } else {
157
+ consola.warn(`File not found: ${resolved}`);
158
+ }
159
+ }
160
+ if (!config.githubAppPrivateKey) {
161
+ config.githubAppPrivateKey = Buffer.from(
162
+ await promptText("Paste GitHub App private key PEM:", config.githubAppPrivateKey),
163
+ ).toString("base64");
164
+ }
165
+ config.githubInstallationId = await promptText(
166
+ "GitHub App installation ID:",
167
+ config.githubInstallationId,
168
+ );
169
+ config.githubToken = undefined;
170
+ }
171
+
104
172
  async function setupLinear(config: AegisCliConfig): Promise<void> {
173
+ printStep("Step 4: optional Linear connection", [
174
+ "Use this only if your team tracks bugs in Linear.",
175
+ "If you only use GitHub Issues, choose no.",
176
+ "You can rerun setup later to add Linear.",
177
+ ]);
105
178
  const enable = (await consola.prompt("Connect Linear too?", {
106
179
  type: "confirm",
107
180
  initial: Boolean(config.linearApiKey),
108
181
  })) as boolean;
109
182
  if (!enable) return;
110
183
 
184
+ printStep("Linear API key", [
185
+ "Open Linear settings, create a personal API key, then paste it here.",
186
+ "Aegis uses it to read ready bug tickets and comment with status updates.",
187
+ ]);
111
188
  config.linearApiKey = await promptText("Linear API key:", config.linearApiKey);
189
+ printStep("Linear webhook secret", [
190
+ "This protects Linear webhook calls after deploy.",
191
+ "Choose yes and Aegis will generate one.",
192
+ ]);
112
193
  const generateSecret = (await consola.prompt("Generate a Linear webhook signing secret?", {
113
194
  type: "confirm",
114
195
  initial: !config.linearWebhookSecret,
@@ -116,11 +197,19 @@ async function setupLinear(config: AegisCliConfig): Promise<void> {
116
197
  config.linearWebhookSecret = generateSecret
117
198
  ? crypto.randomUUID()
118
199
  : await promptText("Linear webhook signing secret:", config.linearWebhookSecret);
200
+ printStep("Linear team/project", [
201
+ "Team ID is required so Aegis knows which Linear team to watch.",
202
+ "Project ID is optional. Leave it blank to watch the whole team.",
203
+ ]);
119
204
  config.linearTeamId = await promptText("Linear team ID:", config.linearTeamId);
120
205
  config.linearProjectId = await promptText(
121
206
  "Linear project ID (optional):",
122
207
  config.linearProjectId,
123
208
  );
209
+ printStep("Linear statuses and labels", [
210
+ "These names must match your Linear workflow.",
211
+ "If your team uses the default names, press Enter through these prompts.",
212
+ ]);
124
213
  config.linearReadyStatus = await promptText(
125
214
  "Linear ready status name:",
126
215
  config.linearReadyStatus ?? "Ready to Implement",
@@ -141,11 +230,21 @@ async function setupLinear(config: AegisCliConfig): Promise<void> {
141
230
  }
142
231
 
143
232
  async function setupAutomation(config: AegisCliConfig): Promise<void> {
233
+ printStep("Step 5: GitHub labels Aegis should watch", [
234
+ "Aegis only picks up open issues with both labels.",
235
+ "Default labels are bug and ready to implement.",
236
+ "Create these labels in GitHub after setup if they do not exist yet.",
237
+ ]);
144
238
  config.readyLabel = await promptText(
145
239
  "GitHub ready label:",
146
240
  config.readyLabel ?? "ready to implement",
147
241
  );
148
242
  config.bugLabel = await promptText("GitHub bug label:", config.bugLabel ?? "bug");
243
+ printStep("Step 6: automation mode", [
244
+ "Choose plan-first for the first real use.",
245
+ "Plan-first means Aegis investigates and asks for approval before changing code.",
246
+ "Auto-low-risk is for later, after you trust the workflow.",
247
+ ]);
149
248
  config.automationMode = (await consola.prompt("Automation mode:", {
150
249
  type: "select",
151
250
  options: [
@@ -154,14 +253,25 @@ async function setupAutomation(config: AegisCliConfig): Promise<void> {
154
253
  ],
155
254
  initial: config.automationMode ?? "plan-first",
156
255
  })) as AegisCliConfig["automationMode"];
256
+ printStep("Step 7: AI model", [
257
+ "Use the default unless you know you want a different Flue-supported model.",
258
+ "You also need the matching provider API key.",
259
+ ]);
157
260
  config.agentModel = await promptText("Flue model:", config.agentModel ?? "openai/gpt-5.1");
158
- config.openaiApiKey = await promptText(
159
- "OpenAI API key (if using an OpenAI model):",
160
- config.openaiApiKey,
161
- );
261
+ printStep("OpenAI API key", [
262
+ "Paste your OpenAI API key if using an openai/... model.",
263
+ "It is stored in .aegis/.env and later copied to Cloudflare secrets during deploy.",
264
+ "Leave blank only if you configured a different model provider yourself.",
265
+ ]);
266
+ config.openaiApiKey = await promptText("OpenAI API key:", config.openaiApiKey);
162
267
  }
163
268
 
164
269
  async function setupProductionContext(config: AegisCliConfig): Promise<void> {
270
+ printStep("Step 8: production context", [
271
+ "Start with Minimal. It uses only issue text and repo code.",
272
+ "Production context lets Aegis inspect read-only database data and Vercel logs.",
273
+ "Do not choose Production until the basic dry-run works.",
274
+ ]);
165
275
  const profile = (await consola.prompt("Context profile:", {
166
276
  type: "select",
167
277
  options: [
@@ -173,7 +283,10 @@ async function setupProductionContext(config: AegisCliConfig): Promise<void> {
173
283
  config.contextProfile = profile;
174
284
  if (profile !== "production") return;
175
285
 
176
- consola.warn("Use a read-only DB user or replica. Do not give Aegis write access.");
286
+ printStep("Production context details", [
287
+ "Use a read-only DB user or read replica. Do not give Aegis write access.",
288
+ "All fields here are optional. Leave unknown fields blank.",
289
+ ]);
177
290
  config.databaseUrl = await promptText("Read-only DATABASE_URL (optional):", config.databaseUrl);
178
291
  config.vercelToken = await promptText("Vercel API token (optional):", config.vercelToken);
179
292
  config.vercelProjectId = await promptText(
@@ -184,14 +297,27 @@ async function setupProductionContext(config: AegisCliConfig): Promise<void> {
184
297
  }
185
298
 
186
299
  async function setupTelegram(config: AegisCliConfig): Promise<void> {
300
+ printStep("Step 9: optional Telegram approval pings", [
301
+ "Use this if you want Aegis to message you when it needs approval.",
302
+ "For the first local dry-run, choose no.",
303
+ "You can rerun setup later to add Telegram.",
304
+ ]);
187
305
  const enable = (await consola.prompt("Enable Telegram approval pings?", {
188
306
  type: "confirm",
189
307
  initial: Boolean(config.telegramBotToken),
190
308
  })) as boolean;
191
309
  if (!enable) return;
192
310
 
311
+ printStep("Telegram bot", [
312
+ "Create a bot with BotFather, paste the bot token, then paste your chat ID.",
313
+ "Aegis will send short approval summaries to that chat.",
314
+ ]);
193
315
  config.telegramBotToken = await promptText("Telegram bot token:", config.telegramBotToken);
194
316
  config.telegramChatId = await promptText("Telegram chat ID:", config.telegramChatId);
317
+ printStep("Telegram webhook secret", [
318
+ "This protects Telegram webhook calls after deploy.",
319
+ "Choose yes and Aegis will generate one.",
320
+ ]);
195
321
  const generateSecret = (await consola.prompt("Generate a Telegram webhook secret?", {
196
322
  type: "confirm",
197
323
  initial: !config.telegramWebhookSecret,
@@ -202,6 +328,11 @@ async function setupTelegram(config: AegisCliConfig): Promise<void> {
202
328
  }
203
329
 
204
330
  async function setupWorker(config: AegisCliConfig): Promise<void> {
331
+ printStep("Step 10: Worker URL", [
332
+ "Leave this blank before your first deploy.",
333
+ "After deploy, Aegis saves the Worker URL automatically.",
334
+ "If you are rerunning setup after deploy, paste the workers.dev URL here.",
335
+ ]);
205
336
  config.workerUrl = await promptText(
206
337
  "Deployed Worker URL (can fill after first deploy):",
207
338
  config.workerUrl,
@@ -215,3 +346,35 @@ async function promptText(message: string, initial: string | undefined): Promise
215
346
  })) as string;
216
347
  return value?.trim() ?? "";
217
348
  }
349
+
350
+ function normalizeGitHubRepo(input: string): string {
351
+ const value = input
352
+ .trim()
353
+ .replace(/\.git$/, "")
354
+ .replace(/\/$/, "");
355
+ const urlMatch = value.match(/github\.com[:/]([^/\s]+)\/([^/\s]+)$/i);
356
+ if (urlMatch) return `${urlMatch[1]}/${urlMatch[2]}`;
357
+ const shorthandMatch = value.match(/^([^/\s]+)\/([^/\s]+)$/);
358
+ if (shorthandMatch) return `${shorthandMatch[1]}/${shorthandMatch[2]}`;
359
+ return value;
360
+ }
361
+
362
+ function detectCurrentBranch(): string {
363
+ try {
364
+ const branch = readFileSync(".git/HEAD", "utf-8").trim();
365
+ if (branch.startsWith("ref: refs/heads/")) {
366
+ return branch.replace("ref: refs/heads/", "");
367
+ }
368
+ } catch {
369
+ // Ignore and fall back to the normal default.
370
+ }
371
+ return "main";
372
+ }
373
+
374
+ function printStep(title: string, lines: string[]): void {
375
+ consola.log(`\n${title}`);
376
+ for (const line of lines) {
377
+ consola.log(` - ${line}`);
378
+ }
379
+ consola.log("");
380
+ }
@@ -0,0 +1,248 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
+ import { type ServerResponse, createServer } from "node:http";
4
+ import { consola } from "consola";
5
+ import {
6
+ type ManifestResult,
7
+ exchangeManifestCode,
8
+ generateManifest,
9
+ getManifestCreationUrl,
10
+ } from "../github/manifest.js";
11
+
12
+ export type GitHubAppFlowResult = ManifestResult & {
13
+ installationId: string;
14
+ };
15
+
16
+ type OwnerType = "Organization" | "User";
17
+
18
+ export async function runGitHubAppFlow(options: {
19
+ monitoredRepo: string;
20
+ workerUrl?: string;
21
+ }): Promise<GitHubAppFlowResult> {
22
+ const [owner, repo] = options.monitoredRepo.split("/");
23
+ if (!owner || !repo) {
24
+ throw new Error("GitHub App setup needs MONITORED_REPO in owner/repo format.");
25
+ }
26
+
27
+ const ownerType = await detectOwnerType(owner);
28
+ const state = randomUUID();
29
+ const appName = buildAppName(owner, repo);
30
+ let createdApp: ManifestResult | undefined;
31
+
32
+ const result = await new Promise<GitHubAppFlowResult>((resolve, reject) => {
33
+ const server = createServer(async (request, response) => {
34
+ try {
35
+ const host = request.headers.host;
36
+ if (!host) {
37
+ writeText(response, 400, "Missing Host header.");
38
+ return;
39
+ }
40
+ const currentUrl = new URL(request.url ?? "/", `http://${host}`);
41
+ const baseUrl = `http://${host}`;
42
+
43
+ if (currentUrl.pathname === "/") {
44
+ const manifest = generateManifest({
45
+ appName,
46
+ redirectUrl: `${baseUrl}/callback`,
47
+ setupUrl: `${baseUrl}/installed`,
48
+ webhookUrl: options.workerUrl
49
+ ? `${options.workerUrl.replace(/\/$/, "")}/webhook/github`
50
+ : "https://example.com/aegis/webhook/github",
51
+ webhookActive: Boolean(options.workerUrl),
52
+ });
53
+ writeHtml(
54
+ response,
55
+ renderManifestForm({
56
+ actionUrl: getManifestCreationUrl(
57
+ ownerType === "Organization" ? owner : undefined,
58
+ state,
59
+ ),
60
+ manifest,
61
+ owner,
62
+ repo,
63
+ appName,
64
+ webhookActive: Boolean(options.workerUrl),
65
+ }),
66
+ );
67
+ return;
68
+ }
69
+
70
+ if (currentUrl.pathname === "/callback") {
71
+ if (currentUrl.searchParams.get("state") !== state) {
72
+ writeText(response, 400, "State mismatch. Close this tab and rerun aegis setup.");
73
+ return;
74
+ }
75
+ const code = currentUrl.searchParams.get("code");
76
+ if (!code) {
77
+ writeText(response, 400, "Missing GitHub manifest code.");
78
+ return;
79
+ }
80
+ createdApp = await exchangeManifestCode(code);
81
+ const installUrl = `${createdApp.htmlUrl}/installations/new`;
82
+ consola.success(`Created GitHub App: ${createdApp.appName}`);
83
+ consola.info(`Install it on ${options.monitoredRepo}: ${installUrl}`);
84
+ writeHtml(response, renderInstallPage(installUrl, options.monitoredRepo));
85
+ return;
86
+ }
87
+
88
+ if (currentUrl.pathname === "/installed") {
89
+ const installationId = currentUrl.searchParams.get("installation_id");
90
+ if (!installationId || !createdApp) {
91
+ writeText(
92
+ response,
93
+ 400,
94
+ "Install callback was missing installation_id. Copy the installation ID from the GitHub URL and rerun setup.",
95
+ );
96
+ return;
97
+ }
98
+ writeHtml(response, renderDonePage());
99
+ resolve({ ...createdApp, installationId });
100
+ setTimeout(() => server.close(), 250);
101
+ return;
102
+ }
103
+
104
+ writeText(response, 404, "Not found.");
105
+ } catch (error) {
106
+ reject(error);
107
+ writeText(response, 500, error instanceof Error ? error.message : String(error));
108
+ setTimeout(() => server.close(), 250);
109
+ }
110
+ });
111
+
112
+ server.on("error", reject);
113
+ server.listen(0, "127.0.0.1", () => {
114
+ const address = server.address();
115
+ if (!address || typeof address === "string") {
116
+ reject(new Error("Could not start local GitHub App setup server."));
117
+ return;
118
+ }
119
+ const url = `http://127.0.0.1:${address.port}/`;
120
+ consola.info("Opening GitHub App setup in your browser.");
121
+ consola.info("Click Create GitHub App, then Install, then select only the monitored repo.");
122
+ consola.info(`If the browser does not open, paste this URL:\n${url}`);
123
+ openBrowser(url);
124
+ });
125
+ });
126
+
127
+ return result;
128
+ }
129
+
130
+ async function detectOwnerType(owner: string): Promise<OwnerType> {
131
+ try {
132
+ const response = await fetch(`https://api.github.com/users/${owner}`, {
133
+ headers: {
134
+ Accept: "application/vnd.github+json",
135
+ "X-GitHub-Api-Version": "2022-11-28",
136
+ },
137
+ });
138
+ if (!response.ok) return "Organization";
139
+ const data = (await response.json()) as { type?: string };
140
+ return data.type === "User" ? "User" : "Organization";
141
+ } catch {
142
+ return "Organization";
143
+ }
144
+ }
145
+
146
+ function buildAppName(owner: string, repo: string): string {
147
+ const suffix = randomBytes(2).toString("hex");
148
+ return `aegis-${owner}-${repo}-${suffix}`
149
+ .toLowerCase()
150
+ .replace(/[^a-z0-9-]/g, "-")
151
+ .replace(/-+/g, "-")
152
+ .slice(0, 34);
153
+ }
154
+
155
+ function openBrowser(url: string): void {
156
+ const command =
157
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
158
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
159
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
160
+ child.unref();
161
+ }
162
+
163
+ function renderManifestForm(options: {
164
+ actionUrl: string;
165
+ manifest: Record<string, unknown>;
166
+ owner: string;
167
+ repo: string;
168
+ appName: string;
169
+ webhookActive: boolean;
170
+ }): string {
171
+ return page(
172
+ "Create Aegis GitHub App",
173
+ `
174
+ <h1>Create Aegis GitHub App</h1>
175
+ <p>This will create a private GitHub App for <strong>${escapeHtml(options.owner)}/${escapeHtml(
176
+ options.repo,
177
+ )}</strong>.</p>
178
+ <ul>
179
+ <li>Permissions: contents, issues, pull requests</li>
180
+ <li>Events: issues, issue comments, pull requests</li>
181
+ <li>App name: ${escapeHtml(options.appName)}</li>
182
+ <li>Webhook: ${options.webhookActive ? "enabled" : "disabled until deploy"}</li>
183
+ </ul>
184
+ <form action="${escapeHtml(options.actionUrl)}" method="post">
185
+ <input type="hidden" name="manifest" value="${escapeHtml(JSON.stringify(options.manifest))}">
186
+ <button type="submit">Create GitHub App</button>
187
+ </form>
188
+ `,
189
+ );
190
+ }
191
+
192
+ function renderInstallPage(installUrl: string, repo: string): string {
193
+ return page(
194
+ "Install Aegis GitHub App",
195
+ `
196
+ <h1>GitHub App created</h1>
197
+ <p>Now install it on <strong>${escapeHtml(repo)}</strong>. Choose <strong>Only select repositories</strong> and pick that repo.</p>
198
+ <p><a class="button" href="${escapeHtml(installUrl)}">Install GitHub App</a></p>
199
+ `,
200
+ );
201
+ }
202
+
203
+ function renderDonePage(): string {
204
+ return page(
205
+ "Aegis GitHub App Connected",
206
+ `
207
+ <h1>Aegis is connected</h1>
208
+ <p>You can close this tab and return to your terminal.</p>
209
+ `,
210
+ );
211
+ }
212
+
213
+ function page(title: string, body: string): string {
214
+ return `<!doctype html>
215
+ <html lang="en">
216
+ <head>
217
+ <meta charset="utf-8">
218
+ <meta name="viewport" content="width=device-width, initial-scale=1">
219
+ <title>${escapeHtml(title)}</title>
220
+ <style>
221
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 680px; margin: 64px auto; padding: 0 24px; color: #111827; line-height: 1.5; }
222
+ h1 { font-size: 28px; margin-bottom: 12px; }
223
+ button, .button { display: inline-block; border: 0; border-radius: 6px; background: #166534; color: white; padding: 10px 14px; font: inherit; text-decoration: none; cursor: pointer; }
224
+ ul { padding-left: 22px; }
225
+ </style>
226
+ </head>
227
+ <body>${body}</body>
228
+ </html>`;
229
+ }
230
+
231
+ function writeHtml(response: ServerResponse, html: string): void {
232
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
233
+ response.end(html);
234
+ }
235
+
236
+ function writeText(response: ServerResponse, status: number, text: string): void {
237
+ response.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
238
+ response.end(text);
239
+ }
240
+
241
+ function escapeHtml(value: string): string {
242
+ return value
243
+ .replace(/&/g, "&amp;")
244
+ .replace(/</g, "&lt;")
245
+ .replace(/>/g, "&gt;")
246
+ .replace(/"/g, "&quot;")
247
+ .replace(/'/g, "&#39;");
248
+ }