diff --git a/src/cli.ts b/src/cli.ts index 0747a28..b4f9d37 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ import { Command } from 'commander' +import { registerConnect } from './commands/connect' import { registerInit } from './commands/init' import { registerPing } from './commands/ping' import { VERSION } from './lib/constants' @@ -11,6 +12,7 @@ program .version(VERSION) registerInit(program) +registerConnect(program) registerPing(program) program.parseAsync(process.argv) diff --git a/src/commands/connect.ts b/src/commands/connect.ts new file mode 100644 index 0000000..1023056 --- /dev/null +++ b/src/commands/connect.ts @@ -0,0 +1,37 @@ +import type { Command } from 'commander' +import { readGithubApp } from '../lib/github_app' +import { currentDeploymentId } from '../lib/paths' +import { readState } from '../lib/state' + +// `ellipsis init` is the wizard and the normal path. `connect` exists only to +// inspect or point at the right re-entry: connection steps read their inputs +// from install state, so they run inside the wizard, never from flags. +export function registerConnect(program: Command): void { + const connect = program.command('connect').description('Connection status for your install') + + connect + .command('github') + .description('Show GitHub App connection status') + .action(() => { + const deploymentId = currentDeploymentId() + const state = readState() + if (!deploymentId || !state) { + console.log('No install in progress. Run `ellipsis init` to get started.') + process.exitCode = 1 + return + } + const app = readGithubApp(deploymentId) + if (app) { + console.log( + `Connected: ${app.name} (app ${app.app_id}, owned by ${app.owner_login}).\n` + + `Manage it at ${app.html_url}.`, + ) + } else { + console.log( + `Not connected. Run \`ellipsis init\` to continue — it will resume at the` + + ` GitHub step for ${state.github_org}.`, + ) + process.exitCode = 1 + } + }) +} diff --git a/src/commands/init.ts b/src/commands/init.ts index e404187..03fc24d 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,13 +1,18 @@ +import { execFile } from 'node:child_process' import type { Command } from 'commander' import { ApiError, registerInstall } from '../lib/api' import { INSTALL_STEPS, renderChecklist } from '../lib/checklist' import { VERSION } from '../lib/constants' -import { readCredentials, writeCredentials } from '../lib/credentials' +import { writeCredentials } from '../lib/credentials' +import { createAppViaManifest, writeGithubApp } from '../lib/github_app' +import { currentDeploymentId } from '../lib/paths' import { ask, askYes, closePrompts, openPrompts } from '../lib/prompt' +import { readState, writeState, type InstallState } from '../lib/state' import { validateAwsAccountId, validateCompany, validateDeveloperCount, + validateDomain, validateEmail, validateGithubOrg, } from '../lib/validate' @@ -39,7 +44,7 @@ export function registerInit(program: Command): void { await runInit() } catch (err) { if ((err as Error).message === 'stdin closed') { - console.error('\nInput ended before the wizard finished. Nothing was created.') + console.error('\nInput ended before the wizard finished.') process.exitCode = 1 } else { throw err @@ -50,24 +55,55 @@ export function registerInit(program: Command): void { }) } +// The wizard: fresh runs start at Step 1; every later `ellipsis init` resumes +// at the first incomplete step, reading answers from install state — a step +// never re-asks what an earlier step already learned. async function runInit(): Promise { - const existing = readCredentials() - if (existing) { - console.log( - `This machine already has an install credential (install ${existing.install_id},` + - ` registered ${existing.registered_at}).\n` + - 'Continuing would register a NEW install. Contact team@ellipsis.dev if you need to reset.', - ) - process.exitCode = 1 + const deploymentId = currentDeploymentId() + const state = readState() + + if (!deploymentId || !state) { + console.log(WELCOME) + console.log('Here is what we will do together:\n') + console.log(renderChecklist(0)) + console.log() + await askYes('Are you ready to get started?') + const fresh = await stepStartTrial() + if (fresh) await continueFrom(fresh.deploymentId, fresh.state) return } - console.log(WELCOME) - console.log('Here is what we will do together:\n') - console.log(renderChecklist(0)) + console.log(`\nWelcome back. Resuming your Ellipsis install for ${state.company}.\n`) + console.log(renderChecklist(state.completed_steps)) console.log() - await askYes('Are you ready to get started?') + if (state.completed_steps >= INSTALL_STEPS.length) { + console.log('Your install is complete.') + return + } + await continueFrom(deploymentId, state) +} + +/** Run steps from the first incomplete one; stop at the first not-yet-built step. */ +async function continueFrom(deploymentId: string, state: InstallState): Promise { + let current = state + while (current.completed_steps < INSTALL_STEPS.length) { + const next = current.completed_steps // 0-indexed + await askYes(`Continue with Step ${next + 1} (${INSTALL_STEPS[next].title})?`) + switch (next) { + case 1: + current = await stepConnectGithub(deploymentId, current) + break + default: + console.log( + `\nStep ${next + 1} (${INSTALL_STEPS[next].title}) is not built yet — coming soon.`, + ) + return + } + } +} +/** Step 1: collect identity, mint the trial, persist credential + state. */ +async function stepStartTrial(): Promise<{ deploymentId: string; state: InstallState } | null> { console.log(`\nStep 1: ${INSTALL_STEPS[0].title}\n`) const email = await ask('What is your work email?', validateEmail) @@ -80,6 +116,11 @@ async function runInit(): Promise { "What is the AWS Account ID you'd like to deploy Ellipsis in?", validateAwsAccountId, ) + const domain = await ask( + 'What domain will your Ellipsis installation use? (e.g. ellipsis.acme.com — you will' + + ' delegate DNS to AWS during the deploy step; nothing needs to exist yet)', + validateDomain, + ) const githubOrg = await ask( "What is the name of the GitHub organization you'd like to connect your self-hosted" + ' Ellipsis installation to? If your company has many GitHub organizations, just choose' + @@ -97,6 +138,7 @@ async function runInit(): Promise { console.log() console.log('Starting your free trial...') + let deploymentId: string try { const res = await registerInstall({ email, @@ -106,14 +148,12 @@ async function runInit(): Promise { github_org: githubOrg, cli_version: VERSION, }) - const path = writeCredentials({ + deploymentId = res.install_id + writeCredentials({ install_id: res.install_id, install_credential: res.install_credential, registered_at: new Date().toISOString(), }) - console.log(`Trial active: 7 days. Credential saved to ${path}.\n`) - console.log(renderChecklist(1)) - console.log('\nNext: `ellipsis init` will continue with Step 2 (coming soon).') } catch (err) { const reason = err instanceof ApiError @@ -124,5 +164,75 @@ async function runInit(): Promise { 'Nothing was created. Please try again shortly, or contact team@ellipsis.dev.', ) process.exitCode = 1 + return null } + + const state: InstallState = { + email, + company, + developer_count: developerCount, + aws_account_id: awsAccountId, + github_org: githubOrg, + domain, + completed_steps: 1, + } + writeState(deploymentId, state) + console.log('Trial active: 7 days.\n') + console.log(renderChecklist(1)) + console.log() + return { deploymentId, state } +} + +function openBrowser(url: string): void { + const cmd = + process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open' + execFile(cmd, [url], (err) => { + if (err) console.log(`Could not open a browser automatically. Visit:\n ${url}`) + }) +} + +/** Step 2: create their GitHub App via the manifest flow. All inputs come from state. */ +async function stepConnectGithub(deploymentId: string, state: InstallState): Promise { + const { github_org: org, domain } = state + const appName = `Ellipsis for ${org}` + + console.log( + `\nStep 2: ${INSTALL_STEPS[1].title}\n\n` + + `We will create a GitHub App for your company:\n` + + ` name: ${appName}\n` + + ` owner: ${org}\n` + + ` webhooks: https://api.${domain}/github/webhook\n\n` + + 'Your browser will open a GitHub page showing the app and its permissions.\n' + + 'One click there creates it; the credentials come back to this terminal directly\n' + + 'and never pass through Ellipsis.\n', + ) + await askYes('Ready?') + + console.log('\nWaiting for you to click "Create GitHub App" in the browser...') + const app = await createAppViaManifest( + { + org, + appName, + webhookUrl: `https://api.${domain}/github/webhook`, + homepageUrl: `https://app.${domain}`, + }, + { openBrowser }, + ) + writeGithubApp(deploymentId, app) + console.log(`\nCreated ${app.name} (app ${app.app_id}, owned by ${app.owner_login}).`) + console.log( + 'Credentials saved locally — the deploy step moves them into your AWS Secrets Manager.\n', + ) + + console.log(`Last part: install the app on ${org} and choose repositories.`) + const installUrl = `https://github.com/apps/${app.slug}/installations/new` + openBrowser(installUrl) + await askYes(`Done installing? (${installUrl})`) + + const updated: InstallState = { ...state, completed_steps: 2 } + writeState(deploymentId, updated) + console.log() + console.log(renderChecklist(2)) + console.log() + return updated } diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index e306cba..849cb9c 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -1,29 +1,35 @@ -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' +import { + currentDeploymentId, + readDeploymentFile, + setCurrentDeployment, + writeDeploymentFile, +} from './paths' -// The install credential issued by POST /v1/installs/register. Stored at -// ~/.ellipsis/credentials.json, chmod 600 — it authenticates every later call -// to license.ellipsis.dev for this install. +// The install credential issued by POST /v1/installs/register. It IS the +// deployment identity: writing it also points current-deployment at it. +// Lives at ~/.ellipsis/deployments/{id}/credentials.json, chmod 600. export interface StoredCredentials { install_id: string install_credential: string registered_at: string } -const CREDENTIALS_DIR = path.join(os.homedir(), '.ellipsis') -const CREDENTIALS_PATH = path.join(CREDENTIALS_DIR, 'credentials.json') +const REL_PATH = 'credentials.json' export function readCredentials(): StoredCredentials | null { + const id = currentDeploymentId() + if (!id) return null + const raw = readDeploymentFile(id, REL_PATH) + if (!raw) return null try { - return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8')) as StoredCredentials + return JSON.parse(raw) as StoredCredentials } catch { return null } } export function writeCredentials(creds: StoredCredentials): string { - fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }) - fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 }) - return CREDENTIALS_PATH + const path = writeDeploymentFile(creds.install_id, REL_PATH, JSON.stringify(creds, null, 2) + '\n') + setCurrentDeployment(creds.install_id) + return path } diff --git a/src/lib/github_app.ts b/src/lib/github_app.ts new file mode 100644 index 0000000..85cb68a --- /dev/null +++ b/src/lib/github_app.ts @@ -0,0 +1,207 @@ +import * as http from 'node:http' +import { listDeploymentDir, readDeploymentFile, writeDeploymentFile } from './paths' + +// GitHub App creation via the app-manifest flow. There is no REST endpoint for +// creating org-owned apps; the flow is: serve a self-submitting form that POSTs +// a manifest JSON to github.com, the org owner clicks "Create GitHub App", +// GitHub redirects back to our localhost listener with a one-time code, and we +// exchange it (POST /app-manifests/{code}/conversions, unauthenticated) for the +// app's full credentials: App ID, private key PEM, webhook secret, client +// id/secret. The customer never copy-pastes a credential. + +export interface GithubAppManifestParams { + org: string + appName: string + webhookUrl: string + homepageUrl: string +} + +export interface CreatedGithubApp { + app_id: number + slug: string + name: string + owner_login: string + pem: string + webhook_secret: string + client_id: string + client_secret: string + html_url: string + created_at: string +} + +export function buildManifest(params: GithubAppManifestParams, redirectUrl: string): object { + return { + name: params.appName, + url: params.homepageUrl, + hook_attributes: { url: params.webhookUrl }, + redirect_url: redirectUrl, + public: false, + default_permissions: { + contents: 'write', + issues: 'write', + pull_requests: 'write', + metadata: 'read', + members: 'read', + checks: 'read', + }, + default_events: [ + 'push', + 'pull_request', + 'pull_request_review', + 'pull_request_review_comment', + 'issues', + 'issue_comment', + ], + } +} + +/** The self-submitting page: a form POSTing the manifest to GitHub's create-app URL. */ +export function renderManifestPage(createUrl: string, manifest: object): string { + const json = JSON.stringify(manifest).replace(/&/g, '&').replace(/"/g, '"') + return ` + +

Redirecting you to GitHub to create the app…

+
+ +
+ +` +} + +const GITHUB_BASE = process.env.ELLIPSIS_GITHUB_BASE_URL ?? 'https://github.com' +const GITHUB_API_BASE = process.env.ELLIPSIS_GITHUB_API_BASE_URL ?? 'https://api.github.com' + +/** + * Run the manifest flow: listen on localhost, open the browser, wait for + * GitHub's redirect, exchange the code. Resolves with the created app. + */ +export async function createAppViaManifest( + params: GithubAppManifestParams, + opts: { openBrowser: (url: string) => void; timeoutMs?: number }, +): Promise { + const createUrl = `${GITHUB_BASE}/organizations/${params.org}/settings/apps/new` + + return new Promise((resolve, reject) => { + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost') + if (url.pathname === '/start') { + const port = (server.address() as { port: number }).port + const manifest = buildManifest(params, `http://localhost:${port}/callback`) + res.writeHead(200, { 'content-type': 'text/html' }) + res.end(renderManifestPage(createUrl, manifest)) + } else if (url.pathname === '/callback') { + const code = url.searchParams.get('code') + if (!code) { + res.writeHead(400, { 'content-type': 'text/plain' }) + res.end('Missing code parameter.') + return + } + try { + const app = await exchangeManifestCode(code) + res.writeHead(200, { 'content-type': 'text/html' }) + res.end('

GitHub App created. You can close this tab and return to your terminal.

') + server.close() + clearTimeout(timer) + resolve(app) + } catch (err) { + res.writeHead(500, { 'content-type': 'text/plain' }) + res.end(`Exchange failed: ${(err as Error).message}`) + server.close() + clearTimeout(timer) + reject(err as Error) + } + } else { + res.writeHead(404) + res.end() + } + }) + + const timer = setTimeout( + () => { + server.close() + reject(new Error('Timed out waiting for the GitHub redirect.')) + }, + opts.timeoutMs ?? 10 * 60 * 1000, + ) + + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as { port: number }).port + opts.openBrowser(`http://localhost:${port}/start`) + }) + server.on('error', (err) => { + clearTimeout(timer) + reject(err) + }) + }) +} + +async function exchangeManifestCode(code: string): Promise { + const res = await fetch(`${GITHUB_API_BASE}/app-manifests/${encodeURIComponent(code)}/conversions`, { + method: 'POST', + headers: { accept: 'application/vnd.github+json' }, + signal: AbortSignal.timeout(15_000), + }) + if (!res.ok) { + throw new Error(`GitHub answered ${res.status} exchanging the manifest code.`) + } + const body = (await res.json()) as { + id: number + slug: string + name: string + owner: { login: string } + pem: string + webhook_secret: string + client_id: string + client_secret: string + html_url: string + } + return { + app_id: body.id, + slug: body.slug, + name: body.name, + owner_login: body.owner.login, + pem: body.pem, + webhook_secret: body.webhook_secret, + client_id: body.client_id, + client_secret: body.client_secret, + html_url: body.html_url, + created_at: new Date().toISOString(), + } +} + +// Created-app credentials live under the deployment until the deploy step +// writes them into the customer's Secrets Manager: +// deployments/{id}/github/apps/{app_id}/secret.pem the private key, alone +// deployments/{id}/github/apps/{app_id}/app.json everything else +// The key gets its own file so it can be shredded independently after the +// deploy step moves it, leaving the harmless metadata behind. + +function appDir(appId: number): string { + return `github/apps/${appId}` +} + +export function writeGithubApp(deploymentId: string, app: CreatedGithubApp): string { + const { pem, ...metadata } = app + const pemPath = writeDeploymentFile(deploymentId, `${appDir(app.app_id)}/secret.pem`, pem) + writeDeploymentFile( + deploymentId, + `${appDir(app.app_id)}/app.json`, + JSON.stringify(metadata, null, 2) + '\n', + ) + return pemPath +} + +export function readGithubApp(deploymentId: string): CreatedGithubApp | null { + const appIds = listDeploymentDir(deploymentId, 'github/apps') + if (appIds.length === 0) return null + // One app per deployment today; take the newest if several exist. + const appId = appIds.sort().at(-1)! + const metaRaw = readDeploymentFile(deploymentId, `github/apps/${appId}/app.json`) + const pem = readDeploymentFile(deploymentId, `github/apps/${appId}/secret.pem`) + if (!metaRaw || pem === null) return null + try { + return { ...(JSON.parse(metaRaw) as Omit), pem } + } catch { + return null + } +} diff --git a/src/lib/paths.ts b/src/lib/paths.ts new file mode 100644 index 0000000..3eae809 --- /dev/null +++ b/src/lib/paths.ts @@ -0,0 +1,63 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +// Everything the CLI stores lives under the deployment it belongs to: +// +// ~/.ellipsis/ +// current-deployment (the deployment id later commands act on) +// deployments/{id}/ +// credentials.json (install credential from /v1/installs/register) +// install-state.json (wizard answers + progress) +// github/apps/{app_id}/ +// app.json (app metadata, no key material) +// secret.pem (the app's private key, until the deploy +// step moves it into their Secrets Manager) +// +// The deployment id is the install_id the license service mints, so nothing +// exists on disk before Step 1 succeeds. + +export const ELLIPSIS_DIR = path.join(os.homedir(), '.ellipsis') +const CURRENT_PATH = path.join(ELLIPSIS_DIR, 'current-deployment') + +export function deploymentDir(deploymentId: string): string { + return path.join(ELLIPSIS_DIR, 'deployments', deploymentId) +} + +export function setCurrentDeployment(deploymentId: string): void { + fs.mkdirSync(ELLIPSIS_DIR, { recursive: true, mode: 0o700 }) + fs.writeFileSync(CURRENT_PATH, deploymentId + '\n', { mode: 0o600 }) +} + +export function currentDeploymentId(): string | null { + try { + const id = fs.readFileSync(CURRENT_PATH, 'utf8').trim() + return id.length > 0 ? id : null + } catch { + return null + } +} + +/** Write a file under the deployment dir, creating parents 700, file 600. */ +export function writeDeploymentFile(deploymentId: string, relPath: string, content: string): string { + const abs = path.join(deploymentDir(deploymentId), relPath) + fs.mkdirSync(path.dirname(abs), { recursive: true, mode: 0o700 }) + fs.writeFileSync(abs, content, { mode: 0o600 }) + return abs +} + +export function readDeploymentFile(deploymentId: string, relPath: string): string | null { + try { + return fs.readFileSync(path.join(deploymentDir(deploymentId), relPath), 'utf8') + } catch { + return null + } +} + +export function listDeploymentDir(deploymentId: string, relPath: string): string[] { + try { + return fs.readdirSync(path.join(deploymentDir(deploymentId), relPath)) + } catch { + return [] + } +} diff --git a/src/lib/state.ts b/src/lib/state.ts new file mode 100644 index 0000000..95b7538 --- /dev/null +++ b/src/lib/state.ts @@ -0,0 +1,37 @@ +import { currentDeploymentId, readDeploymentFile, writeDeploymentFile } from './paths' + +// The install state: every answer the wizard has collected and how far it has +// gotten, at ~/.ellipsis/deployments/{id}/install-state.json. Written after +// each completed step so `ellipsis init` is resumable — later steps read +// answers from here and never re-ask. Key material lives elsewhere (see +// paths.ts for the layout). +export interface InstallState { + // Step 1 answers. + email: string + company: string + developer_count: number + aws_account_id: string + github_org: string + domain: string + // Number of completed steps (1 = trial started, 2 = GitHub connected, ...). + completed_steps: number +} + +const REL_PATH = 'install-state.json' + +export function readState(): InstallState | null { + const id = currentDeploymentId() + if (!id) return null + const raw = readDeploymentFile(id, REL_PATH) + if (!raw) return null + try { + return JSON.parse(raw) as InstallState + } catch { + return null + } +} + +/** State is deployment-scoped: it can only be written once Step 1 minted the id. */ +export function writeState(deploymentId: string, state: InstallState): void { + writeDeploymentFile(deploymentId, REL_PATH, JSON.stringify(state, null, 2) + '\n') +} diff --git a/src/lib/validate.ts b/src/lib/validate.ts index eb7eb15..1f2e8c8 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -34,6 +34,14 @@ export function validateAwsAccountId(input: string): string | Error { return id } +export function validateDomain(input: string): string | Error { + const domain = input.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '') + if (!/^[a-z0-9][a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) { + return new Error('That does not look like a domain, e.g. ellipsis.acme.com.') + } + return domain +} + export function validateGithubOrg(input: string): string | Error { let org = input.trim() // Accept a pasted URL and strip it down to the login. diff --git a/test/github_app.test.ts b/test/github_app.test.ts new file mode 100644 index 0000000..18b14fd --- /dev/null +++ b/test/github_app.test.ts @@ -0,0 +1,107 @@ +import * as http from 'node:http' +import { afterAll, beforeAll, expect, test } from 'vitest' + +// The manifest flow against a stub GitHub: the "browser" here fetches the +// CLI's /start page, submits the manifest form the way a real browser would, +// gets GitHub's redirect, and follows it to the CLI's /callback — then the +// module exchanges the code at the stub API and resolves with the app. + +let github: http.Server +let githubPort: number +const received: { manifest?: object; exchangedCode?: string } = {} + +beforeAll(async () => { + github = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost') + if (req.method === 'POST' && url.pathname.startsWith('/organizations/')) { + // GitHub's create-app form target: record the manifest, redirect with a code. + let body = '' + for await (const chunk of req) body += chunk + const manifestJson = decodeURIComponent(body.replace(/^manifest=/, '').replace(/\+/g, '%20')) + received.manifest = JSON.parse(manifestJson) + const redirect = (received.manifest as { redirect_url: string }).redirect_url + res.writeHead(302, { location: `${redirect}?code=onetime123` }) + res.end() + } else if (req.method === 'POST' && url.pathname.startsWith('/app-manifests/')) { + // The conversions exchange. + received.exchangedCode = url.pathname.split('/')[2] + res.writeHead(201, { 'content-type': 'application/json' }) + res.end( + JSON.stringify({ + id: 4242, + slug: 'ellipsis-for-acme', + name: 'Ellipsis for Acme', + owner: { login: 'acme-platform' }, + pem: '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----\n', + webhook_secret: 'whsec_fake', + client_id: 'Iv1.fake', + client_secret: 'cs_fake', + html_url: 'https://github.com/apps/ellipsis-for-acme', + }), + ) + } else { + res.writeHead(404) + res.end() + } + }) + await new Promise((r) => github.listen(0, '127.0.0.1', r)) + githubPort = (github.address() as { port: number }).port + process.env.ELLIPSIS_GITHUB_BASE_URL = `http://127.0.0.1:${githubPort}` + process.env.ELLIPSIS_GITHUB_API_BASE_URL = `http://127.0.0.1:${githubPort}` +}) + +afterAll(() => { + github.close() +}) + +test('manifest flow end to end against a stub GitHub', async () => { + // Import after env vars are set (module reads them at load). + const { createAppViaManifest } = await import('../src/lib/github_app') + + const appPromise = createAppViaManifest( + { + org: 'acme-platform', + appName: 'Ellipsis for Acme', + webhookUrl: 'https://api.ellipsis.acme.com/github/webhook', + homepageUrl: 'https://app.ellipsis.acme.com', + }, + { + // The fake browser: fetch /start, submit the form to stub-GitHub, follow the redirect. + openBrowser: (startUrl) => { + void (async () => { + const page = await fetch(startUrl).then((r) => r.text()) + const action = page.match(/action="([^"]+)"/)![1] + const value = page + .match(/name="manifest" value="([^"]*)"/)![1] + .replace(/"/g, '"') + .replace(/&/g, '&') + const submit = await fetch(action, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: `manifest=${encodeURIComponent(value)}`, + redirect: 'manual', + }) + const location = submit.headers.get('location')! + await fetch(location) // the browser following GitHub's redirect to /callback + })() + }, + timeoutMs: 5_000, + }, + ) + + const app = await appPromise + expect(app.app_id).toBe(4242) + expect(app.slug).toBe('ellipsis-for-acme') + expect(app.pem).toContain('PRIVATE KEY') + expect(app.webhook_secret).toBe('whsec_fake') + expect(received.exchangedCode).toBe('onetime123') + // The manifest GitHub saw carries our webhook URL and permissions. + const m = received.manifest as { + hook_attributes: { url: string } + default_permissions: Record + public: boolean + } + expect(m.hook_attributes.url).toBe('https://api.ellipsis.acme.com/github/webhook') + expect(m.default_permissions.pull_requests).toBe('write') + expect(m.public).toBe(false) +})