@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 +4 -1
- package/src/github-app-manifest.ts +196 -0
- package/src/index.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/installation-init",
|
|
3
|
-
"version": "0.0.
|
|
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>✓ 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>✗ 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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''')
|
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 {
|