@fabrika/installation-init 0.0.4 → 0.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/installation-init",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,5 +24,8 @@
24
24
  "scripts": {
25
25
  "typecheck": "tsc --noEmit",
26
26
  "test": "bun test"
27
+ },
28
+ "dependencies": {
29
+ "@fabrika/github-app": "^0.0.5"
27
30
  }
28
31
  }
@@ -0,0 +1,196 @@
1
+ import {
2
+ buildGitHubAppManifest,
3
+ type CreatedGitHubApp,
4
+ exchangeGitHubAppManifestCode as exchangeSharedGitHubAppManifestCode,
5
+ type GitHubAppFetch,
6
+ type GitHubAppManifestInput,
7
+ } from '@fabrika/github-app'
8
+ import { randomBytes } from 'node:crypto'
9
+ import { action, detail, info, ok, url } from './log'
10
+
11
+ export { buildGitHubAppManifest }
12
+ export type { CreatedGitHubApp, GitHubAppManifestInput }
13
+ export type GitHubAppManifestFetch = GitHubAppFetch
14
+
15
+ export interface GitHubAppManifestRuntime {
16
+ readonly fetch?: GitHubAppManifestFetch
17
+ readonly callbackTimeoutMs?: number
18
+ readonly persistenceTimeoutMs?: number
19
+ /** Receives the loopback entry point after the server starts. Useful to launch or test the browser handoff. */
20
+ readonly onLocalUrl?: (localUrl: string) => void
21
+ /** Persist the one-time credentials before the browser or caller is told creation succeeded. */
22
+ readonly onCreated?: (app: CreatedGitHubApp, signal: AbortSignal) => Promise<void>
23
+ }
24
+
25
+ const DEFAULT_CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
26
+ const MAX_CALLBACK_TIMEOUT_MS = 60 * 60 * 1000
27
+ const DEFAULT_PERSISTENCE_TIMEOUT_MS = 30 * 1000
28
+ const MAX_PERSISTENCE_TIMEOUT_MS = 5 * 60 * 1000
29
+ const APP_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/
30
+
31
+ /** Backward-compatible positional wrapper around the runtime-neutral GitHub App exchange. */
32
+ export const exchangeGitHubAppManifestCode = (
33
+ code: string,
34
+ fetchImplementation?: GitHubAppManifestFetch,
35
+ timeoutMs?: number,
36
+ ): Promise<CreatedGitHubApp> =>
37
+ exchangeSharedGitHubAppManifestCode(code, {
38
+ ...(fetchImplementation === undefined ? {} : { fetch: fetchImplementation }),
39
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
40
+ })
41
+
42
+ const persistCreatedApp = async (
43
+ app: CreatedGitHubApp,
44
+ onCreated: GitHubAppManifestRuntime['onCreated'],
45
+ timeoutMs: number,
46
+ ): Promise<void> => {
47
+ if (onCreated === undefined) return
48
+ const controller = new AbortController()
49
+ let rejectTimeout: (error: Error) => void
50
+ const timeout = new Promise<never>((_resolve, reject) => {
51
+ rejectTimeout = reject
52
+ })
53
+ const timer = setTimeout(() => {
54
+ rejectTimeout(new Error('GitHub App credential persistence timed out'))
55
+ controller.abort()
56
+ }, timeoutMs)
57
+ try {
58
+ await Promise.race([onCreated(app, controller.signal), timeout])
59
+ if (controller.signal.aborted) throw new Error('GitHub App credential persistence timed out')
60
+ } catch {
61
+ throw new Error('GitHub App credential persistence failed')
62
+ } finally {
63
+ clearTimeout(timer)
64
+ }
65
+ }
66
+
67
+ /** Run GitHub's browser manifest handshake on a loopback-only callback server. */
68
+ export async function createGitHubAppViaManifest(
69
+ input: GitHubAppManifestInput,
70
+ runtime: GitHubAppManifestRuntime = {},
71
+ ): Promise<CreatedGitHubApp> {
72
+ const callbackTimeoutMs = runtime.callbackTimeoutMs ?? DEFAULT_CALLBACK_TIMEOUT_MS
73
+ if (!Number.isSafeInteger(callbackTimeoutMs) || callbackTimeoutMs <= 0 || callbackTimeoutMs > MAX_CALLBACK_TIMEOUT_MS) {
74
+ throw new Error('GitHub App manifest callback timeout is invalid')
75
+ }
76
+ const persistenceTimeoutMs = runtime.persistenceTimeoutMs ?? DEFAULT_PERSISTENCE_TIMEOUT_MS
77
+ if (!Number.isSafeInteger(persistenceTimeoutMs) || persistenceTimeoutMs <= 0 || persistenceTimeoutMs > MAX_PERSISTENCE_TIMEOUT_MS) {
78
+ throw new Error('GitHub App credential persistence timeout is invalid')
79
+ }
80
+ const state = randomBytes(16).toString('hex')
81
+ const manifest = buildGitHubAppManifest(input)
82
+ let resolveApp: (app: CreatedGitHubApp) => void
83
+ let rejectApp: (error: Error) => void
84
+ const appPromise = new Promise<CreatedGitHubApp>((resolve, reject) => {
85
+ resolveApp = resolve
86
+ rejectApp = reject
87
+ })
88
+ let callbackStarted = false
89
+ let callbackTimer: ReturnType<typeof setTimeout> | undefined
90
+ const server = Bun.serve({
91
+ hostname: '127.0.0.1',
92
+ port: 0,
93
+ fetch: () => new Response('starting', { status: 503 }),
94
+ })
95
+ const localUrl = `http://127.0.0.1:${server.port}/`
96
+ const fullManifest = { ...manifest, redirect_url: `${localUrl}callback` }
97
+ try {
98
+ server.reload({
99
+ fetch: async (request: Request): Promise<Response> => {
100
+ const requestUrl = new URL(request.url)
101
+ if (request.headers.get('host') !== `127.0.0.1:${server.port}` || requestUrl.origin !== localUrl.slice(0, -1)) {
102
+ return new Response('not found', { status: 404 })
103
+ }
104
+ if (request.method === 'GET' && requestUrl.pathname === '/') {
105
+ return new Response(renderFormPage(input.organization, fullManifest, state), {
106
+ headers: { 'content-type': 'text/html; charset=utf-8' },
107
+ })
108
+ }
109
+ if (request.method === 'GET' && requestUrl.pathname === '/callback') {
110
+ if (requestUrl.searchParams.get('state') !== state) {
111
+ return new Response(renderErrorPage(), { status: 400, headers: { 'content-type': 'text/html; charset=utf-8' } })
112
+ }
113
+ const code = requestUrl.searchParams.get('code')
114
+ if (callbackStarted || code === null || code === '') {
115
+ return new Response(renderErrorPage(), { status: 409, headers: { 'content-type': 'text/html; charset=utf-8' } })
116
+ }
117
+ callbackStarted = true
118
+ if (callbackTimer !== undefined) clearTimeout(callbackTimer)
119
+ try {
120
+ const app = await exchangeGitHubAppManifestCode(code, runtime.fetch)
121
+ await persistCreatedApp(app, runtime.onCreated, persistenceTimeoutMs)
122
+ resolveApp(app)
123
+ return new Response(renderDonePage(), { headers: { 'content-type': 'text/html; charset=utf-8' } })
124
+ } catch {
125
+ rejectApp(new Error('GitHub App manifest callback failed'))
126
+ return new Response(renderErrorPage(), { status: 400, headers: { 'content-type': 'text/html; charset=utf-8' } })
127
+ }
128
+ }
129
+ return new Response('not found', { status: 404 })
130
+ },
131
+ })
132
+ const timeout = new Promise<never>((_resolve, reject) => {
133
+ callbackTimer = setTimeout(() => reject(new Error('Timed out waiting for the GitHub App manifest callback')), callbackTimeoutMs)
134
+ })
135
+ runtime.onLocalUrl?.(localUrl)
136
+ action('OPERATOR ACTION — create the GitHub App', [
137
+ `1. Open: ${url(localUrl)}`,
138
+ '2. Review the preconfigured GitHub App and click Create GitHub App.',
139
+ '3. Return here after GitHub redirects the browser back to this machine.',
140
+ ])
141
+ info('Waiting for the GitHub App manifest callback…')
142
+ const app = await Promise.race([appPromise, timeout])
143
+ ok(`GitHub App created: ${app.slug} (id ${app.id})`)
144
+ detail(`App settings: ${app.htmlUrl}`)
145
+ // Let Bun flush the callback page before the loopback server disappears.
146
+ await Bun.sleep(25)
147
+ return app
148
+ } catch (error) {
149
+ // The callback failure page needs the same flush window as the success page.
150
+ await Bun.sleep(25)
151
+ throw error
152
+ } finally {
153
+ if (callbackTimer !== undefined) clearTimeout(callbackTimer)
154
+ server.stop(true)
155
+ }
156
+ }
157
+
158
+ export const githubAppInstallationUrl = (slug: string): string => {
159
+ if (!APP_SLUG_PATTERN.test(slug)) throw new Error('GitHub App slug is invalid')
160
+ return `https://github.com/apps/${slug}/installations/new`
161
+ }
162
+
163
+ const renderFormPage = (organization: string, manifest: Readonly<Record<string, unknown>>, state: string): string => {
164
+ const githubUrl = `https://github.com/organizations/${encodeURIComponent(organization)}/settings/apps/new?state=${encodeURIComponent(state)}`
165
+ const manifestJson = htmlEscape(JSON.stringify(manifest))
166
+ return `<!doctype html>
167
+ <html><head><meta charset="utf-8"><title>Create Fabrika GitHub App</title></head>
168
+ <body style="font-family: system-ui; max-width: 40rem; margin: 4rem auto; text-align: center;">
169
+ <h1>Creating the Fabrika GitHub App…</h1>
170
+ <p>Submitting the manifest to GitHub. If nothing happens, click the button below.</p>
171
+ <form id="manifest-form" method="post" action="${htmlEscape(githubUrl)}">
172
+ <input type="hidden" name="manifest" value="${manifestJson}">
173
+ <button type="submit">Continue to GitHub</button>
174
+ </form>
175
+ <script>document.getElementById('manifest-form').submit()</script>
176
+ </body></html>`
177
+ }
178
+
179
+ const renderDonePage = (): string =>
180
+ `<!doctype html>
181
+ <html><head><meta charset="utf-8"><title>Done</title></head>
182
+ <body style="font-family: system-ui; max-width: 40rem; margin: 4rem auto; text-align: center;">
183
+ <h1>&#10003; GitHub App created</h1>
184
+ <p>Return to your terminal to install the App on the selected repositories.</p>
185
+ </body></html>`
186
+
187
+ const renderErrorPage = (): string =>
188
+ `<!doctype html>
189
+ <html><head><meta charset="utf-8"><title>Error</title></head>
190
+ <body style="font-family: system-ui; max-width: 40rem; margin: 4rem auto; text-align: center;">
191
+ <h1>&#10007; GitHub App setup failed</h1>
192
+ <p>Return to your terminal for a detail-free error.</p>
193
+ </body></html>`
194
+
195
+ const htmlEscape = (value: string): string =>
196
+ value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')
package/src/index.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  export { configureEnvironment, type EnvironmentConfig, triggerPlatformWorkflow } from './environment'
2
2
  export { ghRepoExists, hasGhCli } from './gh'
3
+ export {
4
+ buildGitHubAppManifest,
5
+ type CreatedGitHubApp,
6
+ createGitHubAppViaManifest,
7
+ exchangeGitHubAppManifestCode,
8
+ githubAppInstallationUrl,
9
+ type GitHubAppManifestFetch,
10
+ type GitHubAppManifestInput,
11
+ type GitHubAppManifestRuntime,
12
+ } from './github-app-manifest'
3
13
  export { action, detail, fail, info, ok, step, url, warn } from './log'
4
14
  export { confirm, required, retry, secret, secretOrEnv, select, text } from './prompt'
5
15
  export {