@fabrika/installation-init 0.0.26 → 0.3.1
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 -4
- package/src/environment.ts +123 -9
- package/src/index.ts +22 -12
- package/src/protection.ts +125 -0
- package/src/scaffold.ts +49 -20
- package/src/shell.ts +23 -0
- package/src/github-app-manifest.ts +0 -196
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/installation-init",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"test": "bun test"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"
|
|
30
|
-
"@fabrika/
|
|
31
|
-
"@fabrika/iam-contract": "^0.
|
|
29
|
+
"zod": "^3.25.76",
|
|
30
|
+
"@fabrika/app": "^0.3.1",
|
|
31
|
+
"@fabrika/iam-contract": "^0.3.1"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/environment.ts
CHANGED
|
@@ -2,11 +2,24 @@
|
|
|
2
2
|
* Write the collected configuration into the base repo's GitHub **Environment** (not repo-level), so the
|
|
3
3
|
* platform pipeline reads it per-environment. Secret VALUES go to `gh secret set --env` over STDIN (gh does
|
|
4
4
|
* the libsodium encryption; the value never hits argv or a log line); non-secret values go to
|
|
5
|
-
* `gh variable set --env --body`. The environment itself is created (idempotent PUT) first
|
|
5
|
+
* `gh variable set --env --body`. The environment itself is created (idempotent PUT) first, carrying the
|
|
6
|
+
* deployment-branch policy that is the only thing an Environment buys over a repository secret — a policy
|
|
7
|
+
* that restricts nothing until `main` itself carries a branch-protection rule.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
import { detail, ok, step, warn } from './log'
|
|
12
|
+
import {
|
|
13
|
+
DEPLOYMENT_BRANCH_POLICY,
|
|
14
|
+
environmentPolicyCommand,
|
|
15
|
+
environmentReviewerCommand,
|
|
16
|
+
type GhAttempt,
|
|
17
|
+
ghHttpStatus,
|
|
18
|
+
isPlanLimited,
|
|
19
|
+
type ProtectionOutcome,
|
|
20
|
+
unavailableReason,
|
|
21
|
+
} from './protection'
|
|
22
|
+
import { attempt, run, type ShellAttempt, type ShellStep } from './shell'
|
|
10
23
|
|
|
11
24
|
/** A harmless cwd anchor — `gh` ignores cwd when `--repo` is given. */
|
|
12
25
|
const ANCHOR = process.cwd()
|
|
@@ -18,29 +31,130 @@ export interface EnvironmentConfig {
|
|
|
18
31
|
secrets: Record<string, string>
|
|
19
32
|
/** Variable name → value (written via `gh variable set`). */
|
|
20
33
|
vars: Record<string, string>
|
|
34
|
+
/**
|
|
35
|
+
* Told once whether only a protected branch may use this Environment's secrets, so a provider's
|
|
36
|
+
* closing summary can restate it. A callback rather than a return value: both providers already
|
|
37
|
+
* bind this function through a `Promise<void>` collaborator.
|
|
38
|
+
*/
|
|
39
|
+
onProtection?(outcome: ProtectionOutcome): void
|
|
21
40
|
}
|
|
22
41
|
|
|
42
|
+
/** The child processes `configureEnvironment` drives. Replaced wholesale in tests. */
|
|
43
|
+
export interface EnvironmentEffects {
|
|
44
|
+
attempt: GhAttempt
|
|
45
|
+
run(step: ShellStep): Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const defaultEnvironmentEffects: EnvironmentEffects = { attempt, run }
|
|
49
|
+
|
|
23
50
|
/** Create the GitHub Environment and populate its secrets + variables. */
|
|
24
|
-
export async function configureEnvironment(
|
|
51
|
+
export async function configureEnvironment(
|
|
52
|
+
config: EnvironmentConfig,
|
|
53
|
+
effects: EnvironmentEffects = defaultEnvironmentEffects,
|
|
54
|
+
): Promise<void> {
|
|
25
55
|
const { repo, environment, secrets, vars } = config
|
|
26
56
|
step(`Configure the ${environment} GitHub Environment on ${repo}`)
|
|
27
57
|
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
58
|
+
// Never `onProtection?.(await …)`: optional-call short-circuiting would skip creating the environment.
|
|
59
|
+
const protection = await createEnvironment(repo, environment, effects.attempt)
|
|
60
|
+
config.onProtection?.(protection)
|
|
31
61
|
|
|
32
62
|
for (const [name, value] of Object.entries(secrets)) {
|
|
33
63
|
if (value === '') {
|
|
34
64
|
warn(`Skipping secret ${name} — no value (the workflow will fail without it; set it manually).`)
|
|
35
65
|
continue
|
|
36
66
|
}
|
|
37
|
-
await run({ command: 'gh', args: ['secret', 'set', name, '--repo', repo, '--env', environment], cwd: ANCHOR, stdin: value })
|
|
67
|
+
await effects.run({ command: 'gh', args: ['secret', 'set', name, '--repo', repo, '--env', environment], cwd: ANCHOR, stdin: value })
|
|
38
68
|
ok(`secret ${name} set`)
|
|
39
69
|
}
|
|
40
70
|
for (const [name, value] of Object.entries(vars)) {
|
|
41
|
-
await run({ command: 'gh', args: ['variable', 'set', name, '--repo', repo, '--env', environment, '--body', value], cwd: ANCHOR })
|
|
71
|
+
await effects.run({ command: 'gh', args: ['variable', 'set', name, '--repo', repo, '--env', environment, '--body', value], cwd: ANCHOR })
|
|
42
72
|
ok(`variable ${name} set`)
|
|
43
73
|
}
|
|
74
|
+
detail(`Recommended hardening — require your own approval before a deploy: ${environmentReviewerCommand(repo, environment)}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Idempotent create, carrying the deployment-branch policy MERGED over whatever protection rules the
|
|
79
|
+
* environment already has — a PUT replaces the whole object, so a reviewer or wait timer the operator
|
|
80
|
+
* added by hand would otherwise be dropped by the next `init`. A plan that does not offer protection
|
|
81
|
+
* rules refuses the body, so the environment is then created WITHOUT it rather than not at all; any
|
|
82
|
+
* other failure is a real error and is rethrown. Stdout is captured and discarded, never logged.
|
|
83
|
+
*/
|
|
84
|
+
async function createEnvironment(repo: string, environment: string, gh: GhAttempt): Promise<ProtectionOutcome> {
|
|
85
|
+
const command = environmentPolicyCommand(repo, environment)
|
|
86
|
+
const path = `repos/${repo}/environments/${environment}`
|
|
87
|
+
const existing = await gh({ command: 'gh', args: ['api', path], cwd: ANCHOR })
|
|
88
|
+
if (existing.exitCode !== 0 && ghHttpStatus(existing) !== 404) {
|
|
89
|
+
warn(`Could not read ${environment}'s existing protection rules — a reviewer or wait timer set by hand may be reset by this write.`)
|
|
90
|
+
}
|
|
91
|
+
const body = JSON.stringify({ ...existingEnvironmentProtection(existing), deployment_branch_policy: DEPLOYMENT_BRANCH_POLICY })
|
|
92
|
+
const withPolicy = await gh({ command: 'gh', args: ['api', '-X', 'PUT', path, '--input', '-'], cwd: ANCHOR, stdin: body })
|
|
93
|
+
if (withPolicy.exitCode === 0) {
|
|
94
|
+
ok(`Environment ${environment} ready — deployment-branch policy set (it restricts nothing until main carries a protection rule).`)
|
|
95
|
+
return { applied: true, command }
|
|
96
|
+
}
|
|
97
|
+
if (!isPlanLimited(withPolicy)) {
|
|
98
|
+
throw new Error(`\`gh api -X PUT ${path}\` failed (${unavailableReason(withPolicy)}).`)
|
|
99
|
+
}
|
|
100
|
+
const reason = unavailableReason(withPolicy)
|
|
101
|
+
const plain = await gh({ command: 'gh', args: ['api', '-X', 'PUT', path], cwd: ANCHOR })
|
|
102
|
+
if (plain.exitCode !== 0) {
|
|
103
|
+
throw new Error(`\`gh api -X PUT ${path}\` failed (${unavailableReason(plain)}).`)
|
|
104
|
+
}
|
|
105
|
+
warn(`Environment ${environment} has NO deployment-branch policy (${reason}) — any branch that can run the workflow can read its secrets.`)
|
|
106
|
+
detail(`Apply it later: ${command}`)
|
|
107
|
+
return { applied: false, reason, command }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One reviewer in a PUT body: GitHub's GET nests the id under `reviewer`, its PUT wants it flat. */
|
|
111
|
+
interface EnvironmentReviewer {
|
|
112
|
+
readonly type: string
|
|
113
|
+
readonly id: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The protection rules a PUT must carry forward, read off `gh api repos/…/environments/<name>`. */
|
|
117
|
+
export interface ExistingEnvironmentProtection {
|
|
118
|
+
readonly reviewers?: readonly EnvironmentReviewer[]
|
|
119
|
+
readonly wait_timer?: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const protectionSchema = z.object({ protection_rules: z.array(z.unknown()) })
|
|
123
|
+
const waitTimerSchema = z.object({ type: z.literal('wait_timer'), wait_timer: z.number() })
|
|
124
|
+
const reviewersSchema = z.object({ type: z.literal('required_reviewers'), reviewers: z.array(z.unknown()) })
|
|
125
|
+
const reviewerSchema = z.object({ type: z.string(), reviewer: z.object({ id: z.number() }) })
|
|
126
|
+
.transform(({ type, reviewer }) => ({ type, id: reviewer.id }))
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Translate a GET's `protection_rules` into the shape a PUT takes. Anything unrecognised is dropped:
|
|
130
|
+
* a body GitHub rejects would cost the deployment-branch policy, which is what this write is for.
|
|
131
|
+
*/
|
|
132
|
+
export function existingEnvironmentProtection(result: ShellAttempt): ExistingEnvironmentProtection {
|
|
133
|
+
if (result.exitCode !== 0) return {}
|
|
134
|
+
let parsed: unknown
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(result.stdout)
|
|
137
|
+
} catch {
|
|
138
|
+
return {}
|
|
139
|
+
}
|
|
140
|
+
const protection = protectionSchema.safeParse(parsed)
|
|
141
|
+
if (!protection.success) return {}
|
|
142
|
+
const merged: { reviewers?: EnvironmentReviewer[]; wait_timer?: number } = {}
|
|
143
|
+
for (const rule of protection.data.protection_rules) {
|
|
144
|
+
const timer = waitTimerSchema.safeParse(rule)
|
|
145
|
+
if (timer.success) {
|
|
146
|
+
merged.wait_timer = timer.data.wait_timer
|
|
147
|
+
}
|
|
148
|
+
const required = reviewersSchema.safeParse(rule)
|
|
149
|
+
if (required.success) {
|
|
150
|
+
const reviewers = required.data.reviewers.flatMap((entry): EnvironmentReviewer[] => {
|
|
151
|
+
const reviewer = reviewerSchema.safeParse(entry)
|
|
152
|
+
return reviewer.success ? [reviewer.data] : []
|
|
153
|
+
})
|
|
154
|
+
if (reviewers.length > 0) merged.reviewers = reviewers
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return merged
|
|
44
158
|
}
|
|
45
159
|
|
|
46
160
|
/** Trigger the platform workflow (the real deploy runs in GitHub Actions). */
|
package/src/index.ts
CHANGED
|
@@ -9,25 +9,35 @@ export {
|
|
|
9
9
|
type IamAdminClient,
|
|
10
10
|
type IamAdminConnection,
|
|
11
11
|
} from './admin'
|
|
12
|
-
export { configureEnvironment, type EnvironmentConfig, triggerPlatformWorkflow } from './environment'
|
|
13
|
-
export { ghRepoExists, hasGhCli } from './gh'
|
|
14
12
|
export {
|
|
15
|
-
|
|
16
|
-
type
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from './github-app-manifest'
|
|
13
|
+
configureEnvironment,
|
|
14
|
+
type EnvironmentConfig,
|
|
15
|
+
type EnvironmentEffects,
|
|
16
|
+
type ExistingEnvironmentProtection,
|
|
17
|
+
existingEnvironmentProtection,
|
|
18
|
+
triggerPlatformWorkflow,
|
|
19
|
+
} from './environment'
|
|
20
|
+
export { ghRepoExists, hasGhCli } from './gh'
|
|
24
21
|
export { action, detail, fail, info, ok, step, url, warn } from './log'
|
|
25
22
|
export { confirm, required, retry, secret, secretOrEnv, select, text } from './prompt'
|
|
23
|
+
export {
|
|
24
|
+
branchProtectionCommand,
|
|
25
|
+
DEPLOYMENT_BRANCH_POLICY,
|
|
26
|
+
environmentPolicyCommand,
|
|
27
|
+
environmentReviewerCommand,
|
|
28
|
+
type GhAttempt,
|
|
29
|
+
isPlanLimited,
|
|
30
|
+
PLAN_LIMITED,
|
|
31
|
+
probeSidecarBranchProtection,
|
|
32
|
+
type ProtectionOutcome,
|
|
33
|
+
protectSidecarBranch,
|
|
34
|
+
} from './protection'
|
|
26
35
|
export {
|
|
27
36
|
INITIAL_SCAFFOLD_COMMIT_MESSAGE,
|
|
28
37
|
REFRESH_SCAFFOLD_COMMIT_MESSAGE,
|
|
29
38
|
scaffoldSidecarRepository,
|
|
39
|
+
type SidecarScaffoldEffects,
|
|
30
40
|
type SidecarScaffoldInput,
|
|
31
41
|
type SidecarScaffoldResult,
|
|
32
42
|
} from './scaffold'
|
|
33
|
-
export { capture, probe, run, type ShellStep } from './shell'
|
|
43
|
+
export { attempt, capture, probe, run, type ShellAttempt, type ShellStep } from './shell'
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harden the sidecar repository the operator owns. It holds the installation's entire credential set,
|
|
3
|
+
* so a push to `main` must not be able to rewrite the pipeline that reads them. ADR-0025 leaves the
|
|
4
|
+
* repository operator-owned; this only stops it from being weaker than the account it controls.
|
|
5
|
+
*
|
|
6
|
+
* GitHub Free offers neither rule on a PRIVATE repository, so a refusal is REPORTED, never fatal: the
|
|
7
|
+
* operator is told what is unprotected and the exact command that applies it later. Every body travels
|
|
8
|
+
* over stdin (`gh api --input -`), never on argv.
|
|
9
|
+
*
|
|
10
|
+
* An Environment's `protected_branches` policy is only as strong as the branch rule it points at: with no
|
|
11
|
+
* protection rule on `main`, the policy admits `main` and restricts nothing. The two are reported together
|
|
12
|
+
* for that reason.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { detail, ok, warn } from './log'
|
|
16
|
+
import { attempt, type ShellAttempt, type ShellStep } from './shell'
|
|
17
|
+
|
|
18
|
+
/** A harmless cwd anchor — `gh` resolves the repository from the API path, not from cwd. */
|
|
19
|
+
const ANCHOR = process.cwd()
|
|
20
|
+
|
|
21
|
+
/** No force pushes, no deletions, admins included. No required review: `platform upgrade` pushes to `main`. */
|
|
22
|
+
export const BRANCH_PROTECTION_BODY = JSON.stringify({
|
|
23
|
+
required_status_checks: null,
|
|
24
|
+
enforce_admins: true,
|
|
25
|
+
required_pull_request_reviews: null,
|
|
26
|
+
restrictions: null,
|
|
27
|
+
allow_force_pushes: false,
|
|
28
|
+
allow_deletions: false,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** Only a protected branch may use the Environment's secrets — and only where such a rule exists. */
|
|
32
|
+
export const DEPLOYMENT_BRANCH_POLICY = { protected_branches: true, custom_branch_policies: false } as const
|
|
33
|
+
|
|
34
|
+
/** The same policy as a standalone PUT body, for the command an operator runs by hand. */
|
|
35
|
+
export const DEPLOYMENT_BRANCH_POLICY_BODY = JSON.stringify({ deployment_branch_policy: DEPLOYMENT_BRANCH_POLICY })
|
|
36
|
+
|
|
37
|
+
/** Whether one hardening rule is in place, plus the command that applies it when it is not. */
|
|
38
|
+
export interface ProtectionOutcome {
|
|
39
|
+
readonly applied: boolean
|
|
40
|
+
/** One short, printable line saying why it is not applied. Absent when it is. */
|
|
41
|
+
readonly reason?: string
|
|
42
|
+
/** The `gh api` invocation that applies this rule later. */
|
|
43
|
+
readonly command: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Run a `gh` step whose failure is an ANSWER rather than an error. The seam tests drive. */
|
|
47
|
+
export type GhAttempt = (step: ShellStep) => Promise<ShellAttempt>
|
|
48
|
+
|
|
49
|
+
export const branchProtectionCommand = (repo: string): string =>
|
|
50
|
+
`printf '%s' '${BRANCH_PROTECTION_BODY}' | gh api -X PUT repos/${repo}/branches/main/protection --input -`
|
|
51
|
+
|
|
52
|
+
export const environmentPolicyCommand = (repo: string, environment: string): string =>
|
|
53
|
+
`printf '%s' '${DEPLOYMENT_BRANCH_POLICY_BODY}' | gh api -X PUT repos/${repo}/environments/${environment} --input -`
|
|
54
|
+
|
|
55
|
+
/** Recommended, never applied by us: an approval click per deploy is the operator's call. */
|
|
56
|
+
export const environmentReviewerCommand = (repo: string, environment: string): string => {
|
|
57
|
+
const body = '{"reviewers":[{"type":"User","id":%s}],"deployment_branch_policy":{"protected_branches":true,"custom_branch_policies":false}}'
|
|
58
|
+
return `printf '${body}' "$(gh api user --jq .id)" | gh api -X PUT repos/${repo}/environments/${environment} --input -`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The HTTP status `gh` names in its error line (`gh: … (HTTP 403)`), when it names one. */
|
|
62
|
+
export const ghHttpStatus = (result: ShellAttempt): number | undefined => {
|
|
63
|
+
const status = /\(HTTP (\d{3})\)/.exec(result.stderr)?.[1]
|
|
64
|
+
return status === undefined ? undefined : Number(status)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The three statuses GitHub answers when a plan does not offer a protection rule. A 422 can also mean a
|
|
69
|
+
* malformed request, so this is a heuristic — it decides only whether we DEGRADE or give up, never
|
|
70
|
+
* whether something is safe.
|
|
71
|
+
*/
|
|
72
|
+
export const PLAN_LIMITED: ReadonlySet<number> = new Set([403, 404, 422])
|
|
73
|
+
|
|
74
|
+
/** True when GitHub's answer is one this code is willing to continue without the rule on. */
|
|
75
|
+
export const isPlanLimited = (result: ShellAttempt): boolean => {
|
|
76
|
+
const status = ghHttpStatus(result)
|
|
77
|
+
return status !== undefined && PLAN_LIMITED.has(status)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One printable line for a refusal — the plan limit when GitHub named one, the exit code otherwise. */
|
|
81
|
+
export const unavailableReason = (result: ShellAttempt): string => {
|
|
82
|
+
const status = ghHttpStatus(result)
|
|
83
|
+
if (isPlanLimited(result)) {
|
|
84
|
+
return `HTTP ${String(status)} — not offered on this plan for a private repository`
|
|
85
|
+
}
|
|
86
|
+
return `\`gh\` exited ${result.exitCode}${status === undefined ? '' : ` (HTTP ${status})`}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Protect `main` on the sidecar. Reports what is unprotected and never throws. */
|
|
90
|
+
export async function protectSidecarBranch(repo: string, gh: GhAttempt = attempt): Promise<ProtectionOutcome> {
|
|
91
|
+
const command = branchProtectionCommand(repo)
|
|
92
|
+
const result = await gh({
|
|
93
|
+
command: 'gh',
|
|
94
|
+
args: ['api', '-X', 'PUT', `repos/${repo}/branches/main/protection`, '--input', '-'],
|
|
95
|
+
cwd: ANCHOR,
|
|
96
|
+
stdin: BRANCH_PROTECTION_BODY,
|
|
97
|
+
})
|
|
98
|
+
if (result.exitCode === 0) {
|
|
99
|
+
ok('Branch protection on main: no force pushes, no deletions, enforced for admins.')
|
|
100
|
+
return { applied: true, command }
|
|
101
|
+
}
|
|
102
|
+
const reason = unavailableReason(result)
|
|
103
|
+
warn(`main is UNPROTECTED on ${repo} (${reason}) — nothing stops a force-push or a branch delete on main.`)
|
|
104
|
+
detail(`Apply it later: ${command}`)
|
|
105
|
+
return { applied: false, reason, command }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Read-only: does `main` already carry a protection rule? For a repository this run did NOT create — a
|
|
110
|
+
* PUT there would replace whatever the operator configured, so the existing rules are reported, never
|
|
111
|
+
* overwritten. A 404 is GitHub's answer for "no protection rule", which is exactly what we warn about.
|
|
112
|
+
*/
|
|
113
|
+
export async function probeSidecarBranchProtection(repo: string, gh: GhAttempt = attempt): Promise<ProtectionOutcome> {
|
|
114
|
+
const command = branchProtectionCommand(repo)
|
|
115
|
+
const result = await gh({ command: 'gh', args: ['api', `repos/${repo}/branches/main/protection`], cwd: ANCHOR })
|
|
116
|
+
if (result.exitCode === 0) {
|
|
117
|
+
ok('Branch protection on main: already configured (left as the operator set it).')
|
|
118
|
+
return { applied: true, command }
|
|
119
|
+
}
|
|
120
|
+
const status = ghHttpStatus(result)
|
|
121
|
+
const reason = status === 404 ? 'HTTP 404 — no protection rule on main' : unavailableReason(result)
|
|
122
|
+
warn(`main is UNPROTECTED on ${repo} (${reason}) — nothing stops a force-push or a branch delete on main.`)
|
|
123
|
+
detail(`Apply it later: ${command}`)
|
|
124
|
+
return { applied: false, reason, command }
|
|
125
|
+
}
|
package/src/scaffold.ts
CHANGED
|
@@ -7,13 +7,18 @@
|
|
|
7
7
|
* WHAT the files are is the provider's answer, not this module's — `materialize` writes them and `files`
|
|
8
8
|
* lists everything the scaffold ever stages. Scaffold commits carry `[skip ci]`, so a push here cannot
|
|
9
9
|
* deploy anything before `init` has written the GitHub Environment the pipeline reads.
|
|
10
|
+
*
|
|
11
|
+
* A freshly created repository is protected before anything else is written to it. An EXISTING one is
|
|
12
|
+
* only PROBED, never PUT to: its protection is whatever its operator chose, and a blind PUT would replace
|
|
13
|
+
* stricter rules with ours. Either way the outcome is reported, so an unprotected sidecar is never silent.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
import { existsSync } from 'node:fs'
|
|
13
17
|
import { resolve } from 'node:path'
|
|
14
18
|
import { ghRepoExists, hasGhCli } from './gh'
|
|
15
19
|
import { detail, ok, step } from './log'
|
|
16
|
-
import {
|
|
20
|
+
import { probeSidecarBranchProtection, type ProtectionOutcome, protectSidecarBranch } from './protection'
|
|
21
|
+
import { probe, run, type ShellStep } from './shell'
|
|
17
22
|
|
|
18
23
|
export const INITIAL_SCAFFOLD_COMMIT_MESSAGE = 'chore: initial fabrika platform scaffold [skip ci]'
|
|
19
24
|
export const REFRESH_SCAFFOLD_COMMIT_MESSAGE = 'chore: refresh fabrika platform scaffold [skip ci]'
|
|
@@ -33,63 +38,87 @@ export interface SidecarScaffoldInput {
|
|
|
33
38
|
export interface SidecarScaffoldResult {
|
|
34
39
|
readonly dir: string
|
|
35
40
|
readonly created: boolean
|
|
41
|
+
/** Applied for a repository this run created, probed read-only for one that already existed. */
|
|
42
|
+
readonly branchProtection?: ProtectionOutcome
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The child processes and GitHub calls the scaffold drives. Replaced wholesale in tests. */
|
|
46
|
+
export interface SidecarScaffoldEffects {
|
|
47
|
+
hasGhCli(): Promise<boolean>
|
|
48
|
+
repositoryExists(repo: string): Promise<boolean>
|
|
49
|
+
run(step: ShellStep): Promise<void>
|
|
50
|
+
probe(step: ShellStep): Promise<number>
|
|
51
|
+
protect(repo: string): Promise<ProtectionOutcome>
|
|
52
|
+
probeProtection(repo: string): Promise<ProtectionOutcome>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const defaultScaffoldEffects: SidecarScaffoldEffects = {
|
|
56
|
+
hasGhCli,
|
|
57
|
+
repositoryExists: ghRepoExists,
|
|
58
|
+
run,
|
|
59
|
+
probe,
|
|
60
|
+
protect: (repo) => protectSidecarBranch(repo),
|
|
61
|
+
probeProtection: (repo) => probeSidecarBranchProtection(repo),
|
|
36
62
|
}
|
|
37
63
|
|
|
38
64
|
/** True when the staged tree has changes to commit (`git diff --cached --quiet` exits 1 on drift). */
|
|
39
|
-
async function hasStagedChanges(dir: string): Promise<boolean> {
|
|
40
|
-
return (await probe({ command: 'git', args: ['diff', '--cached', '--quiet'], cwd: dir })) !== 0
|
|
65
|
+
async function hasStagedChanges(effects: SidecarScaffoldEffects, dir: string): Promise<boolean> {
|
|
66
|
+
return (await effects.probe({ command: 'git', args: ['diff', '--cached', '--quiet'], cwd: dir })) !== 0
|
|
41
67
|
}
|
|
42
68
|
|
|
43
69
|
/**
|
|
44
70
|
* Ensure the sidecar repository exists and carries the current scaffold. Returns the local checkout.
|
|
45
71
|
* Requires `gh` authenticated with rights to create/administer the repository.
|
|
46
72
|
*/
|
|
47
|
-
export async function scaffoldSidecarRepository(
|
|
73
|
+
export async function scaffoldSidecarRepository(
|
|
74
|
+
input: SidecarScaffoldInput,
|
|
75
|
+
effects: SidecarScaffoldEffects = defaultScaffoldEffects,
|
|
76
|
+
): Promise<SidecarScaffoldResult> {
|
|
48
77
|
step(`Scaffold the sidecar repository (${input.repo})`)
|
|
49
|
-
if (!(await hasGhCli())) {
|
|
78
|
+
if (!(await effects.hasGhCli())) {
|
|
50
79
|
throw new Error('`gh` (GitHub CLI) is required — install it and run `gh auth login`.')
|
|
51
80
|
}
|
|
52
|
-
return (await
|
|
81
|
+
return (await effects.repositoryExists(input.repo)) ? updateExisting(input, effects) : createFresh(input, effects)
|
|
53
82
|
}
|
|
54
83
|
|
|
55
84
|
/** Repository already exists: clone if needed, refresh CLI-owned files, commit + push on drift. */
|
|
56
|
-
async function updateExisting(input: SidecarScaffoldInput): Promise<SidecarScaffoldResult> {
|
|
85
|
+
async function updateExisting(input: SidecarScaffoldInput, effects: SidecarScaffoldEffects): Promise<SidecarScaffoldResult> {
|
|
57
86
|
const { repo, dir } = input
|
|
58
87
|
if (!existsSync(resolve(dir, '.git'))) {
|
|
59
88
|
detail(`Cloning ${repo} → ${dir}`)
|
|
60
|
-
await run({ command: 'gh', args: ['repo', 'clone', repo, dir], cwd: process.cwd() })
|
|
89
|
+
await effects.run({ command: 'gh', args: ['repo', 'clone', repo, dir], cwd: process.cwd() })
|
|
61
90
|
} else {
|
|
62
91
|
detail(`Reusing existing checkout at ${dir}`)
|
|
63
92
|
}
|
|
64
93
|
await input.materialize(dir)
|
|
65
|
-
await run({ command: 'git', args: ['add', ...input.files], cwd: dir })
|
|
66
|
-
if (!(await hasStagedChanges(dir))) {
|
|
94
|
+
await effects.run({ command: 'git', args: ['add', ...input.files], cwd: dir })
|
|
95
|
+
if (!(await hasStagedChanges(effects, dir))) {
|
|
67
96
|
ok('Sidecar repository already up to date (no scaffold drift).')
|
|
68
|
-
return { dir, created: false }
|
|
97
|
+
return { dir, created: false, branchProtection: await effects.probeProtection(repo) }
|
|
69
98
|
}
|
|
70
|
-
await run({ command: 'git', args: ['commit', '-m', REFRESH_SCAFFOLD_COMMIT_MESSAGE], cwd: dir })
|
|
71
|
-
await run({ command: 'git', args: ['push'], cwd: dir })
|
|
99
|
+
await effects.run({ command: 'git', args: ['commit', '-m', REFRESH_SCAFFOLD_COMMIT_MESSAGE], cwd: dir })
|
|
100
|
+
await effects.run({ command: 'git', args: ['push'], cwd: dir })
|
|
72
101
|
ok('Sidecar repository scaffold updated + pushed.')
|
|
73
|
-
return { dir, created: false }
|
|
102
|
+
return { dir, created: false, branchProtection: await effects.probeProtection(repo) }
|
|
74
103
|
}
|
|
75
104
|
|
|
76
105
|
/** Repository does not exist: build a local checkout, commit the scaffold, create + push in one `gh` call. */
|
|
77
|
-
async function createFresh(input: SidecarScaffoldInput): Promise<SidecarScaffoldResult> {
|
|
106
|
+
async function createFresh(input: SidecarScaffoldInput, effects: SidecarScaffoldEffects): Promise<SidecarScaffoldResult> {
|
|
78
107
|
const { repo, dir } = input
|
|
79
108
|
if (existsSync(resolve(dir, '.git'))) {
|
|
80
109
|
throw new Error(`${dir} is already a git repo but ${repo} does not exist on GitHub — resolve the mismatch by hand.`)
|
|
81
110
|
}
|
|
82
111
|
detail(`Creating a fresh checkout at ${dir}`)
|
|
83
|
-
await run({ command: 'git', args: ['init', '-b', 'main', dir], cwd: process.cwd() })
|
|
112
|
+
await effects.run({ command: 'git', args: ['init', '-b', 'main', dir], cwd: process.cwd() })
|
|
84
113
|
await input.materialize(dir)
|
|
85
|
-
await run({ command: 'git', args: ['add', ...input.files], cwd: dir })
|
|
86
|
-
await run({ command: 'git', args: ['commit', '-m', INITIAL_SCAFFOLD_COMMIT_MESSAGE], cwd: dir })
|
|
114
|
+
await effects.run({ command: 'git', args: ['add', ...input.files], cwd: dir })
|
|
115
|
+
await effects.run({ command: 'git', args: ['commit', '-m', INITIAL_SCAFFOLD_COMMIT_MESSAGE], cwd: dir })
|
|
87
116
|
detail(`Creating ${repo} (private) and pushing`)
|
|
88
|
-
await run({
|
|
117
|
+
await effects.run({
|
|
89
118
|
command: 'gh',
|
|
90
119
|
args: ['repo', 'create', repo, '--private', '--source', dir, '--remote', 'origin', '--push'],
|
|
91
120
|
cwd: process.cwd(),
|
|
92
121
|
})
|
|
93
122
|
ok(`Sidecar repository ${repo} created + pushed.`)
|
|
94
|
-
return { dir, created: true }
|
|
123
|
+
return { dir, created: true, branchProtection: await effects.protect(repo) }
|
|
95
124
|
}
|
package/src/shell.ts
CHANGED
|
@@ -106,3 +106,26 @@ export async function probe(step: ShellStep): Promise<number> {
|
|
|
106
106
|
})
|
|
107
107
|
return proc.exited
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
/** What a step that is allowed to fail reported: its exit code plus everything it wrote. */
|
|
111
|
+
export interface ShellAttempt {
|
|
112
|
+
readonly exitCode: number
|
|
113
|
+
readonly stdout: string
|
|
114
|
+
readonly stderr: string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run a step WITHOUT throwing, capturing both pipes. For a call whose failure is an ANSWER rather than
|
|
119
|
+
* an error — a `gh api` PUT that GitHub refuses because the plan does not offer the feature.
|
|
120
|
+
*/
|
|
121
|
+
export async function attempt(step: ShellStep): Promise<ShellAttempt> {
|
|
122
|
+
const proc = Bun.spawn([step.command, ...step.args], {
|
|
123
|
+
cwd: step.cwd,
|
|
124
|
+
env: { ...process.env, ...step.env },
|
|
125
|
+
stdin: step.stdin !== undefined ? new TextEncoder().encode(step.stdin) : 'ignore',
|
|
126
|
+
stdout: 'pipe',
|
|
127
|
+
stderr: 'pipe',
|
|
128
|
+
})
|
|
129
|
+
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
|
|
130
|
+
return { exitCode: await proc.exited, stdout, stderr }
|
|
131
|
+
}
|
|
@@ -1,196 +0,0 @@
|
|
|
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("'", ''')
|