@fabrika/installation-init 0.2.0 → 0.3.2
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/checkout.ts +45 -0
- package/src/confirmations.ts +44 -0
- package/src/environment.ts +17 -11
- package/src/index.ts +4 -11
- package/src/options.ts +35 -0
- package/src/prompt.ts +22 -261
- package/src/shell.ts +1 -3
- 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.2
|
|
3
|
+
"version": "0.3.2",
|
|
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.2
|
|
29
|
+
"zod": "^3.25.76",
|
|
30
|
+
"@fabrika/app": "^0.3.2",
|
|
31
|
+
"@fabrika/iam-contract": "^0.3.2"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/checkout.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** `--checkout=<dir>`: where `platform init` keeps the sidecar repository's local checkout. */
|
|
5
|
+
export const CHECKOUT_FLAG = '--checkout'
|
|
6
|
+
|
|
7
|
+
/** `dir` with every symlink in its existing part resolved, so a link into a repository is seen as inside it. */
|
|
8
|
+
const physicalPath = (dir: string): string => {
|
|
9
|
+
let existing = dir
|
|
10
|
+
while (!existsSync(existing)) {
|
|
11
|
+
const parent = dirname(existing)
|
|
12
|
+
if (parent === existing) return dir
|
|
13
|
+
existing = parent
|
|
14
|
+
}
|
|
15
|
+
return join(realpathSync(existing), relative(existing, dir))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The git work tree that encloses `dir`, not counting `dir` being a checkout itself. */
|
|
19
|
+
const enclosingRepository = (dir: string): string | undefined => {
|
|
20
|
+
let current = dirname(physicalPath(dir))
|
|
21
|
+
while (true) {
|
|
22
|
+
if (existsSync(resolve(current, '.git'))) return current
|
|
23
|
+
const parent = dirname(current)
|
|
24
|
+
if (parent === current) return undefined
|
|
25
|
+
current = parent
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The sidecar checkout directory: `--checkout`, or `defaultName` in the working directory.
|
|
31
|
+
*
|
|
32
|
+
* Refused inside another repository, where it would sit as an untracked nested repository that a
|
|
33
|
+
* blanket `git add` records as a gitlink.
|
|
34
|
+
*/
|
|
35
|
+
export const sidecarCheckoutDir = (explicit: string | undefined, defaultName: string, cwd: string = process.cwd()): string => {
|
|
36
|
+
const dir = resolve(cwd, explicit ?? defaultName)
|
|
37
|
+
const repository = enclosingRepository(dir)
|
|
38
|
+
if (repository !== undefined) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`the sidecar checkout ${dir} would sit inside the git repository at ${repository}. `
|
|
41
|
+
+ `Pass ${CHECKOUT_FLAG}=<dir> outside it, or run init from a directory that is not in a repository`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
return dir
|
|
45
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { confirm as promptConfirm } from './prompt'
|
|
2
|
+
|
|
3
|
+
/** The one question `install` and `init` ask. Injected so a flow can be exercised without a TTY. */
|
|
4
|
+
export interface Confirmations {
|
|
5
|
+
confirm(question: string, defaultYes?: boolean): Promise<boolean>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one way a confirming command runs with nobody watching it.
|
|
10
|
+
*
|
|
11
|
+
* Every confirmation defaults to yes, so a pipe carrying blank lines would answer all of them with
|
|
12
|
+
* nobody reading what they agree to — which is why saying yes unattended is a FLAG and never an
|
|
13
|
+
* inference: it makes the choice explicit, and it puts it in the shell history of whoever ran it.
|
|
14
|
+
*/
|
|
15
|
+
export const UNATTENDED_FLAG = '--yes'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How a command's confirmations are answered — and the refusal when nothing can answer them.
|
|
19
|
+
*
|
|
20
|
+
* Unattended, each question is still printed with the answer it was given, so the run leaves the same
|
|
21
|
+
* record of what was agreed to that an interactive one does.
|
|
22
|
+
*/
|
|
23
|
+
export const confirmations = (
|
|
24
|
+
unattended: boolean,
|
|
25
|
+
stdin: { readonly isTTY?: boolean },
|
|
26
|
+
announce: (line: string) => void,
|
|
27
|
+
whatItDoes: string,
|
|
28
|
+
): Confirmations => {
|
|
29
|
+
if (unattended) {
|
|
30
|
+
return {
|
|
31
|
+
confirm: (question) => {
|
|
32
|
+
announce(`${question} yes (${UNATTENDED_FLAG})`)
|
|
33
|
+
return Promise.resolve(true)
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (stdin.isTTY !== true) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`stdin is not a terminal, so nobody is here to confirm what this command does — it ${whatItDoes}. `
|
|
40
|
+
+ `Run it from a terminal, or pass \`${UNATTENDED_FLAG}\` to answer every confirmation yes unattended`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
return { confirm: (question, defaultYes) => promptConfirm(question, defaultYes) }
|
|
44
|
+
}
|
package/src/environment.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* that restricts nothing until `main` itself carries a branch-protection rule.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { z } from 'zod'
|
|
10
11
|
import { detail, ok, step, warn } from './log'
|
|
11
12
|
import {
|
|
12
13
|
DEPLOYMENT_BRANCH_POLICY,
|
|
@@ -118,7 +119,11 @@ export interface ExistingEnvironmentProtection {
|
|
|
118
119
|
readonly wait_timer?: number
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
const
|
|
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 }))
|
|
122
127
|
|
|
123
128
|
/**
|
|
124
129
|
* Translate a GET's `protection_rules` into the shape a PUT takes. Anything unrecognised is dropped:
|
|
@@ -132,18 +137,19 @@ export function existingEnvironmentProtection(result: ShellAttempt): ExistingEnv
|
|
|
132
137
|
} catch {
|
|
133
138
|
return {}
|
|
134
139
|
}
|
|
135
|
-
|
|
140
|
+
const protection = protectionSchema.safeParse(parsed)
|
|
141
|
+
if (!protection.success) return {}
|
|
136
142
|
const merged: { reviewers?: EnvironmentReviewer[]; wait_timer?: number } = {}
|
|
137
|
-
for (const rule of
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
merged.wait_timer =
|
|
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
|
|
141
147
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
return
|
|
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] : []
|
|
147
153
|
})
|
|
148
154
|
if (reviewers.length > 0) merged.reviewers = reviewers
|
|
149
155
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ export {
|
|
|
9
9
|
type IamAdminClient,
|
|
10
10
|
type IamAdminConnection,
|
|
11
11
|
} from './admin'
|
|
12
|
+
export { CHECKOUT_FLAG, sidecarCheckoutDir } from './checkout'
|
|
13
|
+
export { type Confirmations, confirmations, UNATTENDED_FLAG } from './confirmations'
|
|
12
14
|
export {
|
|
13
15
|
configureEnvironment,
|
|
14
16
|
type EnvironmentConfig,
|
|
@@ -18,18 +20,9 @@ export {
|
|
|
18
20
|
triggerPlatformWorkflow,
|
|
19
21
|
} from './environment'
|
|
20
22
|
export { ghRepoExists, hasGhCli } from './gh'
|
|
21
|
-
export {
|
|
22
|
-
buildGitHubAppManifest,
|
|
23
|
-
type CreatedGitHubApp,
|
|
24
|
-
createGitHubAppViaManifest,
|
|
25
|
-
exchangeGitHubAppManifestCode,
|
|
26
|
-
githubAppInstallationUrl,
|
|
27
|
-
type GitHubAppManifestFetch,
|
|
28
|
-
type GitHubAppManifestInput,
|
|
29
|
-
type GitHubAppManifestRuntime,
|
|
30
|
-
} from './github-app-manifest'
|
|
31
23
|
export { action, detail, fail, info, ok, step, url, warn } from './log'
|
|
32
|
-
export {
|
|
24
|
+
export { type CommandEnv, describeArgument, readEnv, readFlag, requiredSetting, setting, shellWord } from './options'
|
|
25
|
+
export { confirm } from './prompt'
|
|
33
26
|
export {
|
|
34
27
|
branchProtectionCommand,
|
|
35
28
|
DEPLOYMENT_BRANCH_POLICY,
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The reading rules every `fabrika platform … --provider=zerops` parser shares: a flag beats the
|
|
2
|
+
// environment variable beside it, a blank value is an absent one, and a flag given twice is refused.
|
|
3
|
+
|
|
4
|
+
export type CommandEnv = Record<string, string | undefined>
|
|
5
|
+
|
|
6
|
+
export const readFlag = (argv: readonly string[], name: string): string | undefined => {
|
|
7
|
+
const prefix = `${name}=`
|
|
8
|
+
const matches = argv.filter((arg) => arg.startsWith(prefix))
|
|
9
|
+
if (matches.length > 1) {
|
|
10
|
+
throw new Error(`${name} was given more than once`)
|
|
11
|
+
}
|
|
12
|
+
const value = matches[0]?.slice(prefix.length)
|
|
13
|
+
return value === undefined || value.trim() === '' ? undefined : value.trim()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const readEnv = (env: CommandEnv, name: string): string | undefined => {
|
|
17
|
+
const value = env[name]
|
|
18
|
+
return value === undefined || value.trim() === '' ? undefined : value.trim()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const setting = (argv: readonly string[], env: CommandEnv, flag: string, variable: string): string | undefined =>
|
|
22
|
+
readFlag(argv, flag) ?? readEnv(env, variable)
|
|
23
|
+
|
|
24
|
+
export const requiredSetting = (value: string | undefined, flag: string, variable: string): string => {
|
|
25
|
+
if (value === undefined) {
|
|
26
|
+
throw new Error(`${flag}=<value> or ${variable} is required`)
|
|
27
|
+
}
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** How a refusal names an argument: a flag by its name only, never its value, which may be a pasted credential. */
|
|
32
|
+
export const describeArgument = (arg: string): string => arg.startsWith('-') ? `\`${arg.split('=')[0]}\`` : 'a positional argument'
|
|
33
|
+
|
|
34
|
+
/** One argument as a POSIX shell reads it back unchanged, for the rerun command a declined step prints. */
|
|
35
|
+
export const shellWord = (arg: string): string => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`
|
package/src/prompt.ts
CHANGED
|
@@ -1,69 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The one interactive question the fabrika CLI asks: a yes/no confirmation before a step that leaves
|
|
3
|
+
* the operator's machine. Every other input is a flag or an environment variable, and a run with no
|
|
4
|
+
* terminal answers confirmations with `--yes` (`confirmations.ts`), so this reads a TTY only.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* return — the caller routes secrets into a child-process env, never back through `log.ts`.
|
|
8
|
-
*
|
|
9
|
-
* ── Why stdin is read two different ways ──────────────────────────────────────────────────────────
|
|
10
|
-
*
|
|
11
|
-
* A PIPED stdin is read by ONE reader for the whole command. Closing a readline discards whatever it
|
|
12
|
-
* has already buffered, so a fresh interface per question swallows every answer after the first and
|
|
13
|
-
* the command stops on question 2 — which is what made `platform init` undrivable without a PTY.
|
|
14
|
-
*
|
|
15
|
-
* A TTY keeps a fresh interface per question, closed afterwards. A persistent one holds the terminal
|
|
16
|
-
* in raw mode (`-icanon -echo`, measured) between questions, and `init` spawns `git` and `gh` between
|
|
17
|
-
* them — a child inheriting a raw terminal echoes nothing and stair-steps its output.
|
|
6
|
+
* A fresh interface per question, closed afterwards: a persistent one holds the terminal in raw mode
|
|
7
|
+
* (`-icanon -echo`, measured) between questions, and init spawns `git` and `gh` between them — a child
|
|
8
|
+
* inheriting a raw terminal echoes nothing and stair-steps its output.
|
|
18
9
|
*/
|
|
19
10
|
|
|
20
|
-
import { createInterface
|
|
21
|
-
import type { Readable, Writable } from 'node:stream'
|
|
22
|
-
import { fail, info } from './log'
|
|
23
|
-
|
|
24
|
-
/** A free-text prompt. Returns the trimmed answer, or `fallback` when the operator just hits enter. */
|
|
25
|
-
export async function text(question: string, fallback?: string): Promise<string> {
|
|
26
|
-
const suffix = fallback !== undefined && fallback !== '' ? ` [${fallback}]` : ''
|
|
27
|
-
const answer = stripTerminalCruft(await ask(`${question}${suffix}: `)).trim()
|
|
28
|
-
if (answer === '' && fallback !== undefined) {
|
|
29
|
-
return fallback
|
|
30
|
-
}
|
|
31
|
-
return answer
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* A required free-text prompt: re-asks until the operator gives a non-empty answer (or a fallback is
|
|
36
|
-
* supplied and accepted). Used for values the CLI cannot proceed without.
|
|
37
|
-
*/
|
|
38
|
-
export async function required(question: string, fallback?: string): Promise<string> {
|
|
39
|
-
for (;;) {
|
|
40
|
-
const value = await text(question, fallback)
|
|
41
|
-
if (value !== '') {
|
|
42
|
-
return value
|
|
43
|
-
}
|
|
44
|
-
console.log(' (a value is required)')
|
|
45
|
-
}
|
|
46
|
-
}
|
|
11
|
+
import { createInterface } from 'node:readline/promises'
|
|
47
12
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
export async function secret(question: string): Promise<string> {
|
|
55
|
-
process.stdout.write(`${question}: `)
|
|
56
|
-
// While reading, intercept the output stream so readline's own echo of the typed characters is
|
|
57
|
-
// suppressed — only the newline at the end is allowed through, to move the cursor down.
|
|
58
|
-
const muted = muteOutput()
|
|
59
|
-
try {
|
|
60
|
-
const answer = await ask('', question)
|
|
61
|
-
return stripTerminalCruft(answer)
|
|
62
|
-
} finally {
|
|
63
|
-
muted.restore()
|
|
64
|
-
// readline swallowed the echoed newline while muted; emit one so the next line starts fresh.
|
|
65
|
-
process.stdout.write('\n')
|
|
13
|
+
/** A yes/no confirmation. Defaults to `defaultYes` on an empty answer. */
|
|
14
|
+
export async function confirm(question: string, defaultYes = false): Promise<boolean> {
|
|
15
|
+
const hint = defaultYes ? 'Y/n' : 'y/N'
|
|
16
|
+
const answer = stripTerminalCruft(await ask(`${question} [${hint}]: `)).trim().toLowerCase()
|
|
17
|
+
if (answer === '') {
|
|
18
|
+
return defaultYes
|
|
66
19
|
}
|
|
20
|
+
return answer === 'y' || answer === 'yes'
|
|
67
21
|
}
|
|
68
22
|
|
|
69
23
|
/**
|
|
@@ -95,207 +49,14 @@ function stripTerminalCruft(value: string): string {
|
|
|
95
49
|
)
|
|
96
50
|
}
|
|
97
51
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
* source (never the value) — so an operator can pre-set e.g. `CLOUDFLARE_API_TOKEN=… bunx …` and
|
|
102
|
-
* skip the interactive capture entirely (useful when the hidden prompt misbehaves on a given terminal).
|
|
103
|
-
* Otherwise it falls back to `secret()`. The result is always trimmed.
|
|
104
|
-
*/
|
|
105
|
-
export async function secretOrEnv(envName: string, question: string): Promise<string> {
|
|
106
|
-
const fromEnv = process.env[envName]
|
|
107
|
-
if (fromEnv !== undefined && fromEnv.trim() !== '') {
|
|
108
|
-
info(`Using ${envName} from the environment (not prompting).`)
|
|
109
|
-
return fromEnv.trim()
|
|
110
|
-
}
|
|
111
|
-
return (await secret(question)).trim()
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** A yes/no confirmation. Defaults to `defaultYes` on an empty answer. */
|
|
115
|
-
export async function confirm(question: string, defaultYes = false): Promise<boolean> {
|
|
116
|
-
const hint = defaultYes ? 'Y/n' : 'y/N'
|
|
117
|
-
const answer = stripTerminalCruft(await ask(`${question} [${hint}]: `)).trim().toLowerCase()
|
|
118
|
-
if (answer === '') {
|
|
119
|
-
return defaultYes
|
|
120
|
-
}
|
|
121
|
-
return answer === 'y' || answer === 'yes'
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Run an async action that VALIDATES input (verify a token, resolve a path). On a thrown error, show
|
|
126
|
-
* the message and re-ask — so a typo'd or rejected value re-prompts in place instead of crashing the
|
|
127
|
-
* whole CLI and losing every value already entered. Declining the retry re-throws, so the caller's
|
|
128
|
-
* top-level handler still aborts cleanly. The `action` must itself re-read its inputs (do the
|
|
129
|
-
* `secret()`/`text()` prompt INSIDE it) so a retry actually asks again.
|
|
130
|
-
*/
|
|
131
|
-
export async function retry<T>(label: string, action: () => Promise<T>): Promise<T> {
|
|
132
|
-
for (;;) {
|
|
133
|
-
try {
|
|
134
|
-
return await action()
|
|
135
|
-
} catch (error) {
|
|
136
|
-
fail(error instanceof Error ? error.message : String(error))
|
|
137
|
-
if (!(await confirm(`${label} — try again?`, true))) {
|
|
138
|
-
throw error instanceof Error ? error : new Error(String(error))
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* A single-choice menu. Prints the numbered options and returns the chosen value. Re-asks on an
|
|
146
|
-
* out-of-range answer. With exactly one option it returns it without prompting.
|
|
147
|
-
*/
|
|
148
|
-
export async function select<T>(question: string, options: { label: string; value: T }[]): Promise<T> {
|
|
149
|
-
if (options.length === 0) {
|
|
150
|
-
throw new Error('select(): no options to choose from')
|
|
151
|
-
}
|
|
152
|
-
const only = options[0]
|
|
153
|
-
if (options.length === 1 && only !== undefined) {
|
|
154
|
-
return only.value
|
|
155
|
-
}
|
|
156
|
-
console.log(question)
|
|
157
|
-
options.forEach((opt, i) => {
|
|
158
|
-
console.log(` ${i + 1}) ${opt.label}`)
|
|
159
|
-
})
|
|
160
|
-
for (;;) {
|
|
161
|
-
const answer = stripTerminalCruft(await ask(' choose [1]: ')).trim()
|
|
162
|
-
const index = answer === '' ? 0 : Number.parseInt(answer, 10) - 1
|
|
163
|
-
const chosen = options[index]
|
|
164
|
-
if (chosen !== undefined) {
|
|
165
|
-
return chosen.value
|
|
166
|
-
}
|
|
167
|
-
console.log(` (enter a number between 1 and ${options.length})`)
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** One command's reader over a stdin nobody is typing at. */
|
|
172
|
-
export interface PipedPrompt {
|
|
173
|
-
/**
|
|
174
|
-
* Write `query`, then take the next line — from what is already buffered, or from what arrives.
|
|
175
|
-
* `label` names the question in a failure; `secret()` needs it because its own query is empty.
|
|
176
|
-
*/
|
|
177
|
-
ask(query: string, label?: string): Promise<string>
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* A reader that OWNS the stream for the whole command, so several answers piped at once survive.
|
|
182
|
-
*
|
|
183
|
-
* The interface stays open, and every line it emits is queued whether or not a question is waiting for
|
|
184
|
-
* it — that queue is the fix. It idles paused between questions so a finished command still exits, and
|
|
185
|
-
* a stream that ends or faults raises instead of hanging forever on a question nothing will answer.
|
|
186
|
-
*
|
|
187
|
-
* Exported for tests, which drive it over an in-memory stream; the CLI has exactly one, over stdin.
|
|
188
|
-
*/
|
|
189
|
-
export const pipedPrompt = (input: Readable, output: Writable): PipedPrompt => {
|
|
190
|
-
const rl = createInterface({ input, output, terminal: false })
|
|
191
|
-
const buffered: string[] = []
|
|
192
|
-
let waiting: (() => void) | undefined
|
|
193
|
-
let delivered: string | undefined
|
|
194
|
-
let ended = false
|
|
195
|
-
let failure: Error | undefined
|
|
196
|
-
const settle = (line: string | undefined): void => {
|
|
197
|
-
const pending = waiting
|
|
198
|
-
waiting = undefined
|
|
199
|
-
delivered = line
|
|
200
|
-
pending?.()
|
|
201
|
-
}
|
|
202
|
-
rl.on('line', (line: string) => {
|
|
203
|
-
if (waiting === undefined) {
|
|
204
|
-
buffered.push(line)
|
|
205
|
-
return
|
|
206
|
-
}
|
|
207
|
-
rl.pause()
|
|
208
|
-
settle(line)
|
|
209
|
-
})
|
|
210
|
-
rl.on('close', () => {
|
|
211
|
-
ended = true
|
|
212
|
-
settle(undefined)
|
|
213
|
-
})
|
|
214
|
-
// A read fault (EIO on a closed pty, a broken pipe) settles the pending question too. Without this
|
|
215
|
-
// the promise nothing will ever resolve is the whole command's last act. BOTH objects are listened
|
|
216
|
-
// to: readline re-emits the input's error, and an `error` nobody listens for on the interface is an
|
|
217
|
-
// uncaught exception rather than a failed prompt.
|
|
218
|
-
const faulted = (error: Error): void => {
|
|
219
|
-
if (failure !== undefined) {
|
|
220
|
-
return
|
|
221
|
-
}
|
|
222
|
-
ended = true
|
|
223
|
-
failure = new Error(`stdin could not be read: ${error.message}`)
|
|
224
|
-
settle(undefined)
|
|
225
|
-
}
|
|
226
|
-
rl.on('error', faulted)
|
|
227
|
-
input.on('error', faulted)
|
|
228
|
-
return {
|
|
229
|
-
ask: async (query: string, label?: string): Promise<string> => {
|
|
230
|
-
const named = label ?? query
|
|
231
|
-
const ready = buffered.shift()
|
|
232
|
-
if (ready !== undefined) {
|
|
233
|
-
output.write(query)
|
|
234
|
-
// Nothing echoes an answer that came off a pipe, so close the line here: a scripted run's
|
|
235
|
-
// transcript reads one question per line, like the terminal one it stands in for.
|
|
236
|
-
output.write('\n')
|
|
237
|
-
return ready
|
|
238
|
-
}
|
|
239
|
-
if (ended) {
|
|
240
|
-
throw failure ?? unanswered(named)
|
|
241
|
-
}
|
|
242
|
-
if (waiting !== undefined) {
|
|
243
|
-
// One stream, one queue, one reader: two overlapping questions would race for the same line.
|
|
244
|
-
throw new Error('pipedPrompt: a question is already waiting')
|
|
245
|
-
}
|
|
246
|
-
rl.setPrompt(query)
|
|
247
|
-
rl.prompt()
|
|
248
|
-
await new Promise<void>((resolve) => {
|
|
249
|
-
waiting = resolve
|
|
250
|
-
})
|
|
251
|
-
const line = delivered
|
|
252
|
-
delivered = undefined
|
|
253
|
-
if (line === undefined) {
|
|
254
|
-
throw failure ?? unanswered(named)
|
|
255
|
-
}
|
|
256
|
-
output.write('\n')
|
|
257
|
-
return line
|
|
258
|
-
},
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const unanswered = (label: string): Error => {
|
|
263
|
-
const prompt = label.trim().replace(/:$/, '')
|
|
264
|
-
return new Error(
|
|
265
|
-
`stdin ended before ${prompt === '' ? 'a prompt' : `\`${prompt}\``} was answered — a piped run must supply one line per question`,
|
|
266
|
-
)
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** The one piped reader this command has. Created on the first question, never closed by hand. */
|
|
270
|
-
let piped: PipedPrompt | undefined
|
|
271
|
-
|
|
272
|
-
/** Ask one question, on whichever stdin this command was given. `label` names it in a failure. */
|
|
273
|
-
async function ask(query: string, label?: string): Promise<string> {
|
|
274
|
-
if (process.stdin.isTTY === true) {
|
|
275
|
-
const rl: Interface = createInterface({ input: process.stdin, output: process.stdout, terminal: true })
|
|
276
|
-
try {
|
|
277
|
-
return await rl.question(query)
|
|
278
|
-
} finally {
|
|
279
|
-
rl.close()
|
|
280
|
-
}
|
|
52
|
+
async function ask(query: string): Promise<string> {
|
|
53
|
+
if (process.stdin.isTTY !== true) {
|
|
54
|
+
throw new Error('a confirmation needs a terminal; pass --yes to answer every confirmation unattended')
|
|
281
55
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
* Temporarily replace `process.stdout.write` so nothing readline echoes during a secret read reaches
|
|
288
|
-
* the terminal. Returns a `restore()` that puts the original back. We only mute stdout (where the
|
|
289
|
-
* echo goes); stderr is untouched so a crash still surfaces.
|
|
290
|
-
*/
|
|
291
|
-
function muteOutput(): { restore: () => void } {
|
|
292
|
-
const original = process.stdout.write.bind(process.stdout)
|
|
293
|
-
// Swallow everything written while muted. Typed characters and the echoed value go nowhere.
|
|
294
|
-
const muted: typeof process.stdout.write = () => true
|
|
295
|
-
process.stdout.write = muted
|
|
296
|
-
return {
|
|
297
|
-
restore: () => {
|
|
298
|
-
process.stdout.write = original
|
|
299
|
-
},
|
|
56
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true })
|
|
57
|
+
try {
|
|
58
|
+
return await rl.question(query)
|
|
59
|
+
} finally {
|
|
60
|
+
rl.close()
|
|
300
61
|
}
|
|
301
62
|
}
|
package/src/shell.ts
CHANGED
|
@@ -22,9 +22,7 @@ export interface ShellStep {
|
|
|
22
22
|
* What a streaming child gets on stdin.
|
|
23
23
|
*
|
|
24
24
|
* A TTY child may INHERIT it — `git` and `gh` prompt on their own, and a terminal is shared by turns.
|
|
25
|
-
*
|
|
26
|
-
* child inheriting it between two questions consumes the answers nobody has read yet, so the next
|
|
27
|
-
* prompt dies on a line the child ate. `capture` has always used `'ignore'` for the same reason.
|
|
25
|
+
* Without a terminal nobody can answer a child's question, so it gets none and fails instead of waiting.
|
|
28
26
|
*/
|
|
29
27
|
export const childStdin = (step: ShellStep, isTty: boolean = process.stdin.isTTY === true): Uint8Array | 'inherit' | 'ignore' =>
|
|
30
28
|
step.stdin !== undefined ? new TextEncoder().encode(step.stdin) : isTty ? 'inherit' : 'ignore'
|
|
@@ -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("'", ''')
|