@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/installation-init",
3
- "version": "0.3.1",
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.1",
31
- "@fabrika/iam-contract": "^0.3.1"
30
+ "@fabrika/app": "^0.3.2",
31
+ "@fabrika/iam-contract": "^0.3.2"
32
32
  }
33
33
  }
@@ -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 { confirm, required, retry, secret, secretOrEnv, select, text } from './prompt'
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
- * Interactive prompts for the fabrika CLI, over `node:readline/promises`.
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
- * `secret()` is the security-critical one: it reads a value WITHOUT echoing it to the terminal (no
5
- * keystrokes, no final value), so a token/key pasted at the prompt never lands in scrollback or a
6
- * screen-share. Every other prompt echoes normally. None of these functions ever log the value they
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, type Interface } from 'node:readline/promises'
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
- * A HIDDEN-ECHO prompt for secret values. Keystrokes are not echoed and the final value is not
50
- * printed — the line is consumed silently. The captured value is run through `stripTerminalCruft` to
51
- * remove bracketed-paste markers / escape sequences some terminals inject around a paste (which would
52
- * otherwise corrupt a pasted token), then returned. The value is NEVER logged by this module.
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
- * Read a secret from the environment (escape hatch / CI path) OR, failing that, the hidden prompt. If
100
- * `process.env[envName]` is set, its trimmed value is used and we log ONLY that the env var was the
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
- piped ??= pipedPrompt(process.stdin, process.stdout)
283
- return piped.ask(query, label)
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
- * 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.
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'