@fabrika/installation-init 0.3.1 → 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 +3 -3
- package/src/checkout.ts +45 -0
- package/src/confirmations.ts +44 -0
- package/src/index.ts +4 -1
- package/src/options.ts +35 -0
- package/src/prompt.ts +22 -261
- package/src/shell.ts +1 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/installation-init",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"zod": "^3.25.76",
|
|
30
|
-
"@fabrika/app": "^0.3.
|
|
31
|
-
"@fabrika/iam-contract": "^0.3.
|
|
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/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,
|
|
@@ -19,7 +21,8 @@ export {
|
|
|
19
21
|
} from './environment'
|
|
20
22
|
export { ghRepoExists, hasGhCli } from './gh'
|
|
21
23
|
export { action, detail, fail, info, ok, step, url, warn } from './log'
|
|
22
|
-
export {
|
|
24
|
+
export { type CommandEnv, describeArgument, readEnv, readFlag, requiredSetting, setting, shellWord } from './options'
|
|
25
|
+
export { confirm } from './prompt'
|
|
23
26
|
export {
|
|
24
27
|
branchProtectionCommand,
|
|
25
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'
|