@fabrika/installation-init 0.0.21 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/installation-init",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,6 +26,8 @@
26
26
  "test": "bun test"
27
27
  },
28
28
  "dependencies": {
29
- "@fabrika/github-app": "^0.0.21"
29
+ "@fabrika/app": "^0.0.23",
30
+ "@fabrika/github-app": "^0.0.23",
31
+ "@fabrika/iam-contract": "^0.0.23"
30
32
  }
31
33
  }
package/src/admin.ts ADDED
@@ -0,0 +1,300 @@
1
+ /**
2
+ * The first administrator of a fresh installation — the admin-RPC calls that admit them, as mechanics
3
+ * rather than a script.
4
+ *
5
+ * `platform install` ends with a provisioning key and nobody who can sign in. What it takes to fix
6
+ * that is provider-neutral: which procedures, in which order, and what makes a second run change
7
+ * nothing. WHERE an installation's IAM answers, and how an operator names it, is the provider's own
8
+ * question and stays in its installation package.
9
+ *
10
+ * Two traps a live bring-up found are encoded here rather than left to each caller:
11
+ *
12
+ * - **The grant is CROSS-APP (`app: null`).** Grants filter to the calling app, so an `admin` grant
13
+ * scoped to the console's own app id leaves Delivery and Operations working while the Access plane
14
+ * refuses. IAM's own app id is not a registered app, so naming it would dangle instead.
15
+ * - **The two admin inputs disagree.** `grants.create` keys on `principalId` while `principals.get`
16
+ * and `passwords.issueEnrollment` take `PrincipalIdInput`, which keys on `id` — the obvious call
17
+ * answers `400 id: Required`. Hidden here deliberately; harmonizing the surface is its own work.
18
+ *
19
+ * The enrollment URL is a CREDENTIAL IN TRANSIT. It is RETURNED, never logged: nothing in this module
20
+ * prints, and `log.ts` deliberately has no helper that takes a secret value.
21
+ */
22
+
23
+ import { createRpcClient, type InferRpcClient, RpcError, type RpcFetch } from '@fabrika/app'
24
+ import type { GrantDto, IamAdminRpcContract, PrincipalListItem } from '@fabrika/iam-contract'
25
+
26
+ /** The built-in cross-app role. It is the one role that carries `iam.admin`, which the Access plane checks. */
27
+ export const ADMIN_ROLE_KEY = 'admin'
28
+
29
+ /** The typed admin surface, exactly as the console reaches it — one contract, so the two cannot drift. */
30
+ export type IamAdminClient = InferRpcClient<IamAdminRpcContract>
31
+
32
+ /**
33
+ * The slice of that surface this flow calls, and nothing wider.
34
+ *
35
+ * `IamAdminClient` satisfies it, so the real client is passed straight in; a caller testing the flow
36
+ * stands up five procedures instead of the whole administration API.
37
+ */
38
+ export interface FirstAdministratorApi {
39
+ readonly principals: Pick<IamAdminClient['principals'], 'get' | 'invite' | 'list'>
40
+ readonly grants: Pick<IamAdminClient['grants'], 'create'>
41
+ readonly passwords: Pick<IamAdminClient['passwords'], 'issueEnrollment'>
42
+ }
43
+
44
+ export interface IamAdminConnection {
45
+ /** IAM's PUBLIC origin — the address the proxy fronts, e.g. `https://iam.example.com`. */
46
+ readonly origin: string
47
+ /** The installation's `px_` provisioning key, sent as `Authorization: Bearer`. */
48
+ readonly provisioningKey: string
49
+ /** Override `fetch` (tests). Defaults to the global one. */
50
+ readonly fetch?: RpcFetch
51
+ }
52
+
53
+ /**
54
+ * A client for `<origin>/admin/rpc` authenticated by the provisioning key.
55
+ *
56
+ * Bearer AND NOTHING ELSE, which is what makes it work at all: IAM's cross-origin guard exempts a
57
+ * request carrying only `Authorization`, and refuses a credential-less one whatever the admin-origin
58
+ * registry holds (`iam/src/admin/router.ts`).
59
+ */
60
+ export const createIamAdminClient = ({ origin, provisioningKey, fetch: fetcher }: IamAdminConnection): IamAdminClient => {
61
+ const call = fetcher ?? fetch
62
+ return createRpcClient<IamAdminRpcContract>({
63
+ baseUrl: `${origin.replace(/\/+$/, '')}/admin/rpc`,
64
+ bounceOnAuth: false,
65
+ fetch: (input, init) => {
66
+ const headers = new Headers(init?.headers)
67
+ headers.set('authorization', `Bearer ${provisioningKey}`)
68
+ return call(input, { ...init, headers })
69
+ },
70
+ })
71
+ }
72
+
73
+ export interface FirstAdministratorRequest {
74
+ /** The mailbox that identifies the human. Any spelling; IAM stores the normalized form. */
75
+ readonly email: string
76
+ /**
77
+ * Issue a second enrollment URL when one is already outstanding. Default false, so a re-run prints
78
+ * nothing new — but an enrollment that expired unopened would otherwise be unrecoverable, because
79
+ * the only other way to issue one is a console nobody can sign in to yet.
80
+ */
81
+ readonly reissueEnrollment?: boolean
82
+ }
83
+
84
+ /** What happened to the password enrollment. Only `issued` carries a URL, and only once. */
85
+ export type EnrollmentOutcome =
86
+ | { readonly state: 'issued'; readonly url: string; readonly expiresAt: number }
87
+ | { readonly state: 'emailed'; readonly email: string; readonly expiresAt: number }
88
+ | { readonly state: 'outstanding' }
89
+ | { readonly state: 'already-set' }
90
+
91
+ export interface FirstAdministratorResult {
92
+ readonly principalId: string
93
+ /** The normalized mailbox, as IAM holds it. */
94
+ readonly email: string
95
+ readonly principal: 'invited' | 'existing'
96
+ readonly grant: 'created' | 'present'
97
+ readonly enrollment: EnrollmentOutcome
98
+ }
99
+
100
+ /** `q` is a substring filter, so a page is read and then matched exactly. */
101
+ const PAGE_LIMIT = 50
102
+
103
+ /** A mailbox matches at most a handful of rows; more than this many pages means the filter is not narrowing. */
104
+ const MAX_PAGES = 20
105
+
106
+ /**
107
+ * IAM's own mailbox normalization (`normalizeEmailIdentity`), restated.
108
+ *
109
+ * `principals.email` is THE NORMALIZED MAILBOX and every comparison against it is plain equality, so a
110
+ * caller matching a listed row has to fold the same way. Three operations, and they are the contract:
111
+ * never widen this without widening IAM's.
112
+ */
113
+ const normalizeMailbox = (value: string): string => value.normalize('NFC').trim().toLocaleLowerCase('en-US')
114
+
115
+ const assertMailbox = (email: string): void => {
116
+ if (!/^[^\s@]+@[^\s@]+$/.test(email)) {
117
+ throw new Error(`\`${email}\` is not an email address`)
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Report an admin-RPC failure as one short line naming the step.
123
+ *
124
+ * Fail closed and say why: an unreachable origin, a refused key and a 4xx are three different things
125
+ * to fix. The message comes off the error envelope, which carries IAM's own wording — never the
126
+ * request, which is the only place a credential appears.
127
+ */
128
+ const failed = (what: string, cause: unknown): Error => {
129
+ if (!(cause instanceof RpcError)) {
130
+ return cause instanceof Error ? new Error(`${what} failed: ${cause.message}`) : new Error(`${what} failed`)
131
+ }
132
+ if (cause.type === 'transport') {
133
+ // httpStatus 0 is a request that never got an answer; anything else answered in a shape the RPC
134
+ // client could not read at all — an HTML error page from something that is not IAM.
135
+ return cause.httpStatus === 0
136
+ ? new Error(`${what} failed: IAM did not answer (${cause.message})`)
137
+ : new Error(`${what} failed: ${cause.message} — is that IAM's public host?`)
138
+ }
139
+ const status = cause.httpStatus === undefined ? '' : ` (HTTP ${cause.httpStatus})`
140
+ return new Error(`${what} failed: ${cause.message}${status}`)
141
+ }
142
+
143
+ /**
144
+ * One admin-RPC call, failing closed on everything that is not a result.
145
+ *
146
+ * The `undefined` guard is not defensive typing. The RPC client unwraps `{ result }`, throws on
147
+ * `{ error }` — and returns `undefined` for a JSON body carrying NEITHER, which is exactly what a
148
+ * misdirected host answers when its own 404 happens to be JSON. Measured: the flow then failed on a
149
+ * property of `undefined` several calls later instead of on the wrong address.
150
+ */
151
+ const step = async <T>(what: string, call: () => Promise<T>): Promise<T> => {
152
+ let result: T
153
+ try {
154
+ result = await call()
155
+ } catch (cause) {
156
+ throw failed(what, cause)
157
+ }
158
+ if (result === undefined) {
159
+ throw new Error(`${what} answered no result — the address answered, but not as an IAM admin RPC surface`)
160
+ }
161
+ return result
162
+ }
163
+
164
+ /** Find the principal holding this mailbox, paging because `q` filters by substring and never by identity. */
165
+ const findByEmail = async (client: FirstAdministratorApi, email: string): Promise<PrincipalListItem | undefined> => {
166
+ let before: string | undefined
167
+ for (let page = 0; page < MAX_PAGES; page += 1) {
168
+ const listed = await step(
169
+ 'principals.list',
170
+ () => client.principals.list({ type: 'user', q: email, limit: PAGE_LIMIT, ...(before === undefined ? {} : { before }) }),
171
+ )
172
+ const match = listed.items.find((item) => item.email !== null && normalizeMailbox(item.email) === email)
173
+ if (match !== undefined) {
174
+ return match
175
+ }
176
+ if (listed.nextCursor === null) {
177
+ return undefined
178
+ }
179
+ before = listed.nextCursor
180
+ }
181
+ throw new Error(
182
+ `read ${MAX_PAGES * PAGE_LIMIT} principals matching \`${email}\` without finding that exact mailbox — `
183
+ + 'the directory is larger than this command can scan',
184
+ )
185
+ }
186
+
187
+ /**
188
+ * Invite the mailbox, tolerating the one race this command can lose.
189
+ *
190
+ * IAM answers 409 when the mailbox already exists, which two concurrent runs produce and a re-run that
191
+ * raced its own predecessor produces too. Re-reading rather than failing is what makes both runs
192
+ * converge on ONE principal — and the re-read is authoritative, so nothing is assumed about who won.
193
+ */
194
+ const admitPrincipal = async (
195
+ client: FirstAdministratorApi,
196
+ spelling: string,
197
+ mailbox: string,
198
+ ): Promise<{ principal: PrincipalListItem; state: 'invited' | 'existing' }> => {
199
+ try {
200
+ // The spelling the operator typed, NOT the folded one: IAM normalizes the mailbox itself and keeps
201
+ // what it was handed as the principal's label.
202
+ return { principal: await client.principals.invite({ email: spelling }), state: 'invited' }
203
+ } catch (cause) {
204
+ if (!(cause instanceof RpcError) || cause.httpStatus !== 409) {
205
+ throw failed('principals.invite', cause)
206
+ }
207
+ const raced = await findByEmail(client, mailbox)
208
+ if (raced === undefined) {
209
+ throw failed('principals.invite', cause)
210
+ }
211
+ return { principal: raced, state: 'existing' }
212
+ }
213
+ }
214
+
215
+ /** The grant this command would create, already present: cross-app, global, role `admin`, still valid. */
216
+ const isCrossAppAdminGrant = (grant: GrantDto, now: number): boolean =>
217
+ grant.app === null
218
+ && grant.roleKey === ADMIN_ROLE_KEY
219
+ && grant.scopeType === null
220
+ && grant.scopeValue === null
221
+ && (grant.expiresAt === null || grant.expiresAt > now)
222
+
223
+ /**
224
+ * Admit the first administrator, idempotently.
225
+ *
226
+ * Four states are reused rather than rewritten: an existing principal for this mailbox, an existing
227
+ * cross-app `admin` grant, an outstanding enrollment, and a password that is already set. A re-run
228
+ * therefore invites nobody twice, grants nothing twice and issues no second URL — and a run that loses
229
+ * the invite race to a concurrent one converges on the principal that won.
230
+ */
231
+ export const ensureFirstAdministrator = async (
232
+ client: FirstAdministratorApi,
233
+ request: FirstAdministratorRequest,
234
+ ): Promise<FirstAdministratorResult> => {
235
+ const email = normalizeMailbox(request.email)
236
+ assertMailbox(email)
237
+
238
+ const found = await findByEmail(client, email)
239
+ const admitted = found === undefined
240
+ ? await admitPrincipal(client, request.email.trim(), email)
241
+ : { principal: found, state: 'existing' as const }
242
+ const principal = admitted.principal
243
+ if (principal.status === 'disabled') {
244
+ throw new Error(
245
+ `principal ${principal.id} holds ${email} and is disabled — a disabled principal cannot sign in. Name a different `
246
+ + 'mailbox, or re-enable this one with `principals.update` ({ id, disabled: false }) before running this again',
247
+ )
248
+ }
249
+
250
+ // `principals.get` keys on `id`; `grants.create` below keys on `principalId`. Same principal, two
251
+ // spellings — this is the difference the command hides.
252
+ const detail = await step('principals.get', () => client.principals.get({ id: principal.id }))
253
+ const now = Math.floor(Date.now() / 1000)
254
+ const granted = detail.grants.some((grant) => isCrossAppAdminGrant(grant, now))
255
+ if (!granted) {
256
+ // `app: null` is the whole point: an app-scoped admin grant leaves the Access plane refusing.
257
+ await step('grants.create', () => client.grants.create({ principalId: principal.id, roleKey: ADMIN_ROLE_KEY, app: null }))
258
+ }
259
+
260
+ return {
261
+ principalId: principal.id,
262
+ email,
263
+ principal: admitted.state,
264
+ grant: granted ? 'present' : 'created',
265
+ enrollment: await ensureEnrollment(client, principal.id, detail.authentication.password.state, request.reissueEnrollment === true),
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Issue the password enrollment EXACTLY ONCE.
271
+ *
272
+ * `enabled` means a password is already set — IAM answers 409 and there is nothing to admit. `pending`
273
+ * means an enrollment was already issued and never completed; a second URL is not issued for it,
274
+ * because "print the URL once" is the property a re-run has to keep, and `reissue` is the explicit way
275
+ * out when the first one expired unopened. `unavailable` means the installation offers no password
276
+ * method at all, which no URL can fix, so it fails rather than reporting a half-admitted administrator.
277
+ */
278
+ const ensureEnrollment = async (
279
+ client: FirstAdministratorApi,
280
+ principalId: string,
281
+ state: 'unavailable' | 'disabled' | 'pending' | 'enabled',
282
+ reissue: boolean,
283
+ ): Promise<EnrollmentOutcome> => {
284
+ if (state === 'unavailable') {
285
+ throw new Error(
286
+ 'this installation offers no password authentication, so no enrollment can be issued — the principal and its '
287
+ + 'cross-app admin grant are in place; enable the password method and run this again',
288
+ )
289
+ }
290
+ if (state === 'enabled') {
291
+ return { state: 'already-set' }
292
+ }
293
+ if (state === 'pending' && !reissue) {
294
+ return { state: 'outstanding' }
295
+ }
296
+ const delivery = await step('passwords.issueEnrollment', () => client.passwords.issueEnrollment({ id: principalId }))
297
+ return delivery.delivery === 'manual'
298
+ ? { state: 'issued', url: delivery.url, expiresAt: delivery.expiresAt }
299
+ : { state: 'emailed', email: delivery.email, expiresAt: delivery.expiresAt }
300
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,14 @@
1
+ export {
2
+ ADMIN_ROLE_KEY,
3
+ createIamAdminClient,
4
+ type EnrollmentOutcome,
5
+ ensureFirstAdministrator,
6
+ type FirstAdministratorApi,
7
+ type FirstAdministratorRequest,
8
+ type FirstAdministratorResult,
9
+ type IamAdminClient,
10
+ type IamAdminConnection,
11
+ } from './admin'
1
12
  export { configureEnvironment, type EnvironmentConfig, triggerPlatformWorkflow } from './environment'
2
13
  export { ghRepoExists, hasGhCli } from './gh'
3
14
  export {
package/src/prompt.ts CHANGED
@@ -1,28 +1,34 @@
1
1
  /**
2
- * Interactive prompts for the fabrika CLI, over `node:readline/promises` on the real TTY.
2
+ * Interactive prompts for the fabrika CLI, over `node:readline/promises`.
3
3
  *
4
4
  * `secret()` is the security-critical one: it reads a value WITHOUT echoing it to the terminal (no
5
5
  * keystrokes, no final value), so a token/key pasted at the prompt never lands in scrollback or a
6
6
  * screen-share. Every other prompt echoes normally. None of these functions ever log the value they
7
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.
8
18
  */
9
19
 
10
20
  import { createInterface, type Interface } from 'node:readline/promises'
21
+ import type { Readable, Writable } from 'node:stream'
11
22
  import { fail, info } from './log'
12
23
 
13
24
  /** A free-text prompt. Returns the trimmed answer, or `fallback` when the operator just hits enter. */
14
25
  export async function text(question: string, fallback?: string): Promise<string> {
15
- const rl = openReadline()
16
- try {
17
- const suffix = fallback !== undefined && fallback !== '' ? ` [${fallback}]` : ''
18
- const answer = stripTerminalCruft(await rl.question(`${question}${suffix}: `)).trim()
19
- if (answer === '' && fallback !== undefined) {
20
- return fallback
21
- }
22
- return answer
23
- } finally {
24
- rl.close()
26
+ const suffix = fallback !== undefined && fallback !== '' ? ` [${fallback}]` : ''
27
+ const answer = stripTerminalCruft(await ask(`${question}${suffix}: `)).trim()
28
+ if (answer === '' && fallback !== undefined) {
29
+ return fallback
25
30
  }
31
+ return answer
26
32
  }
27
33
 
28
34
  /**
@@ -47,16 +53,14 @@ export async function required(question: string, fallback?: string): Promise<str
47
53
  */
48
54
  export async function secret(question: string): Promise<string> {
49
55
  process.stdout.write(`${question}: `)
50
- const rl = openReadline()
51
56
  // While reading, intercept the output stream so readline's own echo of the typed characters is
52
57
  // suppressed — only the newline at the end is allowed through, to move the cursor down.
53
58
  const muted = muteOutput()
54
59
  try {
55
- const answer = await rl.question('')
60
+ const answer = await ask('', question)
56
61
  return stripTerminalCruft(answer)
57
62
  } finally {
58
63
  muted.restore()
59
- rl.close()
60
64
  // readline swallowed the echoed newline while muted; emit one so the next line starts fresh.
61
65
  process.stdout.write('\n')
62
66
  }
@@ -109,17 +113,12 @@ export async function secretOrEnv(envName: string, question: string): Promise<st
109
113
 
110
114
  /** A yes/no confirmation. Defaults to `defaultYes` on an empty answer. */
111
115
  export async function confirm(question: string, defaultYes = false): Promise<boolean> {
112
- const rl = openReadline()
113
- try {
114
- const hint = defaultYes ? 'Y/n' : 'y/N'
115
- const answer = stripTerminalCruft(await rl.question(`${question} [${hint}]: `)).trim().toLowerCase()
116
- if (answer === '') {
117
- return defaultYes
118
- }
119
- return answer === 'y' || answer === 'yes'
120
- } finally {
121
- rl.close()
116
+ const hint = defaultYes ? 'Y/n' : 'y/N'
117
+ const answer = stripTerminalCruft(await ask(`${question} [${hint}]: `)).trim().toLowerCase()
118
+ if (answer === '') {
119
+ return defaultYes
122
120
  }
121
+ return answer === 'y' || answer === 'yes'
123
122
  }
124
123
 
125
124
  /**
@@ -158,25 +157,130 @@ export async function select<T>(question: string, options: { label: string; valu
158
157
  options.forEach((opt, i) => {
159
158
  console.log(` ${i + 1}) ${opt.label}`)
160
159
  })
161
- const rl = openReadline()
162
- try {
163
- for (;;) {
164
- const answer = stripTerminalCruft(await rl.question(' choose [1]: ')).trim()
165
- const index = answer === '' ? 0 : Number.parseInt(answer, 10) - 1
166
- const chosen = options[index]
167
- if (chosen !== undefined) {
168
- return chosen.value
169
- }
170
- console.log(` (enter a number between 1 and ${options.length})`)
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
171
166
  }
172
- } finally {
173
- rl.close()
167
+ console.log(` (enter a number between 1 and ${options.length})`)
174
168
  }
175
169
  }
176
170
 
177
- /** Open a fresh readline interface bound to the process stdio. */
178
- function openReadline(): Interface {
179
- return createInterface({ input: process.stdin, output: process.stdout, terminal: true })
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
+ }
281
+ }
282
+ piped ??= pipedPrompt(process.stdin, process.stdout)
283
+ return piped.ask(query, label)
180
284
  }
181
285
 
182
286
  /**
package/src/shell.ts CHANGED
@@ -18,6 +18,17 @@ export interface ShellStep {
18
18
  stdin?: string
19
19
  }
20
20
 
21
+ /**
22
+ * What a streaming child gets on stdin.
23
+ *
24
+ * A TTY child may INHERIT it — `git` and `gh` prompt on their own, and a terminal is shared by turns.
25
+ * A piped stdin may not: `prompt.ts` gives one reader the whole stream for the command's life, and a
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.
28
+ */
29
+ export const childStdin = (step: ShellStep, isTty: boolean = process.stdin.isTTY === true): Uint8Array | 'inherit' | 'ignore' =>
30
+ step.stdin !== undefined ? new TextEncoder().encode(step.stdin) : isTty ? 'inherit' : 'ignore'
31
+
21
32
  /**
22
33
  * Run a step, STREAMING its stdout/stderr to this terminal so the operator watches the underlying
23
34
  * tool live (git/gh print progress). Throws on a non-zero exit. The thrown message names the command
@@ -32,7 +43,7 @@ export async function run(step: ShellStep): Promise<void> {
32
43
  const proc = Bun.spawn([step.command, ...step.args], {
33
44
  cwd: step.cwd,
34
45
  env: { ...process.env, ...step.env },
35
- stdin: step.stdin !== undefined ? new TextEncoder().encode(step.stdin) : 'inherit',
46
+ stdin: childStdin(step),
36
47
  stdout: 'pipe',
37
48
  stderr: 'pipe',
38
49
  })