@fabrika/installation-init 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@fabrika/installation-init",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/contember/fabrika-platform.git",
8
+ "directory": "packages/installation-init"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/__tests__/**"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "bun": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "default": "./src/index.ts"
22
+ }
23
+ },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "bun test"
27
+ }
28
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Write the collected configuration into the base repo's GitHub **Environment** (not repo-level), so the
3
+ * platform pipeline reads it per-environment. Secret VALUES go to `gh secret set --env` over STDIN (gh does
4
+ * the libsodium encryption; the value never hits argv or a log line); non-secret values go to
5
+ * `gh variable set --env --body`. The environment itself is created (idempotent PUT) first.
6
+ */
7
+
8
+ import { ok, step, warn } from './log'
9
+ import { capture, run } from './shell'
10
+
11
+ /** A harmless cwd anchor — `gh` ignores cwd when `--repo` is given. */
12
+ const ANCHOR = process.cwd()
13
+
14
+ export interface EnvironmentConfig {
15
+ repo: string
16
+ environment: string
17
+ /** Secret name → value (written via `gh secret set`). */
18
+ secrets: Record<string, string>
19
+ /** Variable name → value (written via `gh variable set`). */
20
+ vars: Record<string, string>
21
+ }
22
+
23
+ /** Create the GitHub Environment and populate its secrets + variables. */
24
+ export async function configureEnvironment(config: EnvironmentConfig): Promise<void> {
25
+ const { repo, environment, secrets, vars } = config
26
+ step(`Configure the ${environment} GitHub Environment on ${repo}`)
27
+
28
+ // Idempotent create (PUT returns the env object on stdout — captured + discarded, never logged).
29
+ await capture({ command: 'gh', args: ['api', '-X', 'PUT', `repos/${repo}/environments/${environment}`], cwd: ANCHOR })
30
+ ok(`Environment ${environment} ready.`)
31
+
32
+ for (const [name, value] of Object.entries(secrets)) {
33
+ if (value === '') {
34
+ warn(`Skipping secret ${name} — no value (the workflow will fail without it; set it manually).`)
35
+ continue
36
+ }
37
+ await run({ command: 'gh', args: ['secret', 'set', name, '--repo', repo, '--env', environment], cwd: ANCHOR, stdin: value })
38
+ ok(`secret ${name} set`)
39
+ }
40
+ for (const [name, value] of Object.entries(vars)) {
41
+ await run({ command: 'gh', args: ['variable', 'set', name, '--repo', repo, '--env', environment, '--body', value], cwd: ANCHOR })
42
+ ok(`variable ${name} set`)
43
+ }
44
+ }
45
+
46
+ /** Trigger the platform workflow (the real deploy runs in GitHub Actions). */
47
+ export async function triggerPlatformWorkflow(repo: string): Promise<void> {
48
+ await run({ command: 'gh', args: ['workflow', 'run', 'platform.yml', '--repo', repo], cwd: ANCHOR })
49
+ }
package/src/gh.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `gh` CLI helpers. The CLI shells out to `gh` for everything GitHub-side (repo create, secrets, the
3
+ * deploy trigger) rather than re-implementing the REST API + libsodium secret encryption — `gh` already
4
+ * does the encryption for `secret set`. These are the small probes shared across scaffold + environment.
5
+ */
6
+
7
+ import { probe } from './shell'
8
+
9
+ /** The repo root — a harmless cwd anchor for `gh` shell-outs (gh ignores cwd when `--repo` is given). */
10
+ const ANCHOR = process.cwd()
11
+
12
+ /** Is the `gh` CLI available + authenticated? `gh auth status` exits non-zero when not logged in. */
13
+ export async function hasGhCli(): Promise<boolean> {
14
+ try {
15
+ return (await probe({ command: 'gh', args: ['auth', 'status'], cwd: ANCHOR })) === 0
16
+ } catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ /** Does `<org>/<repo>` exist and is it visible to the current `gh` login? */
22
+ export async function ghRepoExists(repo: string): Promise<boolean> {
23
+ return (await probe({ command: 'gh', args: ['repo', 'view', repo, '--json', 'nameWithOwner'], cwd: ANCHOR })) === 0
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { configureEnvironment, type EnvironmentConfig, triggerPlatformWorkflow } from './environment'
2
+ export { ghRepoExists, hasGhCli } from './gh'
3
+ export { action, detail, fail, info, ok, step, url, warn } from './log'
4
+ export { confirm, required, retry, secret, secretOrEnv, select, text } from './prompt'
5
+ export {
6
+ INITIAL_SCAFFOLD_COMMIT_MESSAGE,
7
+ REFRESH_SCAFFOLD_COMMIT_MESSAGE,
8
+ scaffoldSidecarRepository,
9
+ type SidecarScaffoldInput,
10
+ type SidecarScaffoldResult,
11
+ } from './scaffold'
12
+ export { capture, probe, run, type ShellStep } from './shell'
package/src/log.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Console formatting for the fabrika CLI — a thin, consistent presentation layer over the orchestration
3
+ * in the other modules. Every line the operator sees during `init` flows through here.
4
+ *
5
+ * HARD RULE (see the worker CLAUDE.md): this module NEVER prints a secret VALUE. Callers may pass
6
+ * secret NAMES, counts, or "✓ set" confirmations — never the bytes of a token / key / PEM / webhook
7
+ * secret. There is deliberately no helper here that takes a secret value, so a careless caller can't
8
+ * accidentally route one through formatting.
9
+ */
10
+
11
+ const BOLD = '\x1b[1m'
12
+ const DIM = '\x1b[2m'
13
+ const RESET = '\x1b[0m'
14
+ const GREEN = '\x1b[32m'
15
+ const YELLOW = '\x1b[33m'
16
+ const RED = '\x1b[31m'
17
+ const CYAN = '\x1b[36m'
18
+
19
+ let stepNo = 0
20
+
21
+ /** A numbered, bold section heading — the spine of the CLI's progress. Resets nothing else. */
22
+ export function step(title: string): void {
23
+ stepNo += 1
24
+ console.log(`\n${BOLD}${CYAN}[${stepNo}] ${title}${RESET}`)
25
+ }
26
+
27
+ /** A plain informational line, slightly indented under the current step. */
28
+ export function info(message: string): void {
29
+ console.log(` ${message}`)
30
+ }
31
+
32
+ /** A dim, secondary detail line (context the operator usually skims). */
33
+ export function detail(message: string): void {
34
+ console.log(` ${DIM}${message}${RESET}`)
35
+ }
36
+
37
+ /** A success line (green ✓). */
38
+ export function ok(message: string): void {
39
+ console.log(` ${GREEN}✓${RESET} ${message}`)
40
+ }
41
+
42
+ /** A non-fatal warning (yellow ⚠) — the CLI keeps going. */
43
+ export function warn(message: string): void {
44
+ console.log(` ${YELLOW}⚠${RESET} ${message}`)
45
+ }
46
+
47
+ /** A failure line (red ✗). Does NOT exit — the caller decides whether to throw. */
48
+ export function fail(message: string): void {
49
+ console.log(` ${RED}✗${RESET} ${message}`)
50
+ }
51
+
52
+ /**
53
+ * A boxed OPERATOR ACTION — something the human must do out-of-band (open a URL, log in, click
54
+ * "create", paste a value back). These are the hand-off points the whole bring-up is built around, so
55
+ * they are visually loud and never scroll past unnoticed.
56
+ */
57
+ export function action(title: string, lines: string[]): void {
58
+ const width = Math.max(title.length, ...lines.map((l) => stripAnsi(l).length)) + 4
59
+ const bar = '─'.repeat(width)
60
+ console.log(`\n${YELLOW}┌${bar}┐${RESET}`)
61
+ console.log(`${YELLOW}│${RESET} ${BOLD}${title}${RESET}${' '.repeat(width - title.length - 2)}${YELLOW}│${RESET}`)
62
+ if (lines.length > 0) {
63
+ console.log(`${YELLOW}├${bar}┤${RESET}`)
64
+ for (const line of lines) {
65
+ const visibleLen = stripAnsi(line).length
66
+ console.log(`${YELLOW}│${RESET} ${line}${' '.repeat(width - visibleLen - 2)}${YELLOW}│${RESET}`)
67
+ }
68
+ }
69
+ console.log(`${YELLOW}└${bar}┘${RESET}`)
70
+ }
71
+
72
+ /** Render a URL prominently (cyan, underlined) — operators copy these by eye. */
73
+ export function url(value: string): string {
74
+ return `${CYAN}\x1b[4m${value}${RESET}`
75
+ }
76
+
77
+ /** Strip ANSI escape codes so box-drawing can measure the VISIBLE width of a line. */
78
+ function stripAnsi(value: string): string {
79
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape sequences by design.
80
+ return value.replace(/\x1b\[[0-9;]*m/g, '')
81
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Interactive prompts for the fabrika CLI, over `node:readline/promises` on the real TTY.
3
+ *
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
+
10
+ import { createInterface, type Interface } from 'node:readline/promises'
11
+ import { fail, info } from './log'
12
+
13
+ /** A free-text prompt. Returns the trimmed answer, or `fallback` when the operator just hits enter. */
14
+ 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()
25
+ }
26
+ }
27
+
28
+ /**
29
+ * A required free-text prompt: re-asks until the operator gives a non-empty answer (or a fallback is
30
+ * supplied and accepted). Used for values the CLI cannot proceed without.
31
+ */
32
+ export async function required(question: string, fallback?: string): Promise<string> {
33
+ for (;;) {
34
+ const value = await text(question, fallback)
35
+ if (value !== '') {
36
+ return value
37
+ }
38
+ console.log(' (a value is required)')
39
+ }
40
+ }
41
+
42
+ /**
43
+ * A HIDDEN-ECHO prompt for secret values. Keystrokes are not echoed and the final value is not
44
+ * printed — the line is consumed silently. The captured value is run through `stripTerminalCruft` to
45
+ * remove bracketed-paste markers / escape sequences some terminals inject around a paste (which would
46
+ * otherwise corrupt a pasted token), then returned. The value is NEVER logged by this module.
47
+ */
48
+ export async function secret(question: string): Promise<string> {
49
+ process.stdout.write(`${question}: `)
50
+ const rl = openReadline()
51
+ // While reading, intercept the output stream so readline's own echo of the typed characters is
52
+ // suppressed — only the newline at the end is allowed through, to move the cursor down.
53
+ const muted = muteOutput()
54
+ try {
55
+ const answer = await rl.question('')
56
+ return stripTerminalCruft(answer)
57
+ } finally {
58
+ muted.restore()
59
+ rl.close()
60
+ // readline swallowed the echoed newline while muted; emit one so the next line starts fresh.
61
+ process.stdout.write('\n')
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Strip terminal control cruft from a captured answer: bracketed-paste markers (`ESC[200~`/`ESC[201~`),
67
+ * any CSI/OSC escape sequence, and stray control characters. A real token / key / id / domain / email
68
+ * never contains these, so removing them is safe — and it fixes two real cases on an interactive TTY:
69
+ * - a paste arriving with bracketed-paste markers embedded; and
70
+ * - a terminal's OSC 10/11 color-query RESPONSE leaking into a later prompt. Some CLIs (notably `gh`,
71
+ * via Go's termenv) query the background color with `ESC]11;?`; the terminal correctly replies with
72
+ * `ESC]11;rgb:RRRR/GGGG/BBBB`, but that reply can land in the next readline read. In `terminal:true`
73
+ * mode readline's keypress parser eats the `ESC]`, leaving the bare `11;rgb:…` payload in the line —
74
+ * so we strip both the full escape sequence AND that bare leftover.
75
+ */
76
+ function stripTerminalCruft(value: string): string {
77
+ return (
78
+ value
79
+ // Full OSC sequences: ESC ] … (BEL or ST). e.g. an OSC 10/11 dynamic-color response.
80
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal escape bytes by design.
81
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
82
+ // Bare OSC 10/11 color responses whose ESC introducer readline's keypress parser ate
83
+ // (`1X;rgb:RRRR/GGGG/BBBB`, optional leading `]`). Never appears in a real entered value.
84
+ .replace(/\]?1[0-9];rgb:[0-9a-fA-F]{1,4}(?:\/[0-9a-fA-F]{1,4}){2}/g, '')
85
+ // CSI sequences (bracketed-paste markers, cursor-position responses ESC[N;MR, SGR, …).
86
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal escape bytes by design.
87
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
88
+ // Any stray C0/C1 control char + DEL.
89
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal control bytes by design.
90
+ .replace(/[\x00-\x1f\x7f]/g, '')
91
+ )
92
+ }
93
+
94
+ /**
95
+ * Read a secret from the environment (escape hatch / CI path) OR, failing that, the hidden prompt. If
96
+ * `process.env[envName]` is set, its trimmed value is used and we log ONLY that the env var was the
97
+ * source (never the value) — so an operator can pre-set e.g. `CLOUDFLARE_API_TOKEN=… bunx …` and
98
+ * skip the interactive capture entirely (useful when the hidden prompt misbehaves on a given terminal).
99
+ * Otherwise it falls back to `secret()`. The result is always trimmed.
100
+ */
101
+ export async function secretOrEnv(envName: string, question: string): Promise<string> {
102
+ const fromEnv = process.env[envName]
103
+ if (fromEnv !== undefined && fromEnv.trim() !== '') {
104
+ info(`Using ${envName} from the environment (not prompting).`)
105
+ return fromEnv.trim()
106
+ }
107
+ return (await secret(question)).trim()
108
+ }
109
+
110
+ /** A yes/no confirmation. Defaults to `defaultYes` on an empty answer. */
111
+ 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()
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Run an async action that VALIDATES input (verify a token, resolve a path). On a thrown error, show
127
+ * the message and re-ask — so a typo'd or rejected value re-prompts in place instead of crashing the
128
+ * whole CLI and losing every value already entered. Declining the retry re-throws, so the caller's
129
+ * top-level handler still aborts cleanly. The `action` must itself re-read its inputs (do the
130
+ * `secret()`/`text()` prompt INSIDE it) so a retry actually asks again.
131
+ */
132
+ export async function retry<T>(label: string, action: () => Promise<T>): Promise<T> {
133
+ for (;;) {
134
+ try {
135
+ return await action()
136
+ } catch (error) {
137
+ fail(error instanceof Error ? error.message : String(error))
138
+ if (!(await confirm(`${label} — try again?`, true))) {
139
+ throw error instanceof Error ? error : new Error(String(error))
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * A single-choice menu. Prints the numbered options and returns the chosen value. Re-asks on an
147
+ * out-of-range answer. With exactly one option it returns it without prompting.
148
+ */
149
+ export async function select<T>(question: string, options: { label: string; value: T }[]): Promise<T> {
150
+ if (options.length === 0) {
151
+ throw new Error('select(): no options to choose from')
152
+ }
153
+ const only = options[0]
154
+ if (options.length === 1 && only !== undefined) {
155
+ return only.value
156
+ }
157
+ console.log(question)
158
+ options.forEach((opt, i) => {
159
+ console.log(` ${i + 1}) ${opt.label}`)
160
+ })
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})`)
171
+ }
172
+ } finally {
173
+ rl.close()
174
+ }
175
+ }
176
+
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 })
180
+ }
181
+
182
+ /**
183
+ * Temporarily replace `process.stdout.write` so nothing readline echoes during a secret read reaches
184
+ * the terminal. Returns a `restore()` that puts the original back. We only mute stdout (where the
185
+ * echo goes); stderr is untouched so a crash still surfaces.
186
+ */
187
+ function muteOutput(): { restore: () => void } {
188
+ const original = process.stdout.write.bind(process.stdout)
189
+ // Swallow everything written while muted. Typed characters and the echoed value go nowhere.
190
+ const muted: typeof process.stdout.write = () => true
191
+ process.stdout.write = muted
192
+ return {
193
+ restore: () => {
194
+ process.stdout.write = original
195
+ },
196
+ }
197
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The mechanics of a sidecar repository: ensure `<owner>/<name>` exists on GitHub, materialize the
3
+ * files the CLI owns, commit and push. Idempotent, and the same on every provider:
4
+ * - repo absent → create a fresh local checkout, commit the scaffold, `gh repo create … --source --push`,
5
+ * - repo present → clone it (if not already local), refresh the CLI-owned files, commit + push on drift.
6
+ *
7
+ * WHAT the files are is the provider's answer, not this module's — `materialize` writes them and `files`
8
+ * lists everything the scaffold ever stages. Scaffold commits carry `[skip ci]`, so a push here cannot
9
+ * deploy anything before `init` has written the GitHub Environment the pipeline reads.
10
+ */
11
+
12
+ import { existsSync } from 'node:fs'
13
+ import { resolve } from 'node:path'
14
+ import { ghRepoExists, hasGhCli } from './gh'
15
+ import { detail, ok, step } from './log'
16
+ import { probe, run } from './shell'
17
+
18
+ export const INITIAL_SCAFFOLD_COMMIT_MESSAGE = 'chore: initial fabrika platform scaffold [skip ci]'
19
+ export const REFRESH_SCAFFOLD_COMMIT_MESSAGE = 'chore: refresh fabrika platform scaffold [skip ci]'
20
+
21
+ export interface SidecarScaffoldInput {
22
+ /** The sidecar repository, e.g. `contember/fabrika-zerops-test`. */
23
+ readonly repo: string
24
+ /** Local directory to check the repository out into. */
25
+ readonly dir: string
26
+ /** Every path the scaffold stages — CLI-owned files plus any operator-owned, write-once pin. */
27
+ readonly files: readonly string[]
28
+ /** Write the scaffold into an existing directory. Called for a fresh creation and for a refresh alike. */
29
+ materialize(dir: string): Promise<void>
30
+ }
31
+
32
+ /** Result of scaffolding: the local checkout dir + whether the repository was freshly created. */
33
+ export interface SidecarScaffoldResult {
34
+ readonly dir: string
35
+ readonly created: boolean
36
+ }
37
+
38
+ /** True when the staged tree has changes to commit (`git diff --cached --quiet` exits 1 on drift). */
39
+ async function hasStagedChanges(dir: string): Promise<boolean> {
40
+ return (await probe({ command: 'git', args: ['diff', '--cached', '--quiet'], cwd: dir })) !== 0
41
+ }
42
+
43
+ /**
44
+ * Ensure the sidecar repository exists and carries the current scaffold. Returns the local checkout.
45
+ * Requires `gh` authenticated with rights to create/administer the repository.
46
+ */
47
+ export async function scaffoldSidecarRepository(input: SidecarScaffoldInput): Promise<SidecarScaffoldResult> {
48
+ step(`Scaffold the sidecar repository (${input.repo})`)
49
+ if (!(await hasGhCli())) {
50
+ throw new Error('`gh` (GitHub CLI) is required — install it and run `gh auth login`.')
51
+ }
52
+ return (await ghRepoExists(input.repo)) ? updateExisting(input) : createFresh(input)
53
+ }
54
+
55
+ /** Repository already exists: clone if needed, refresh CLI-owned files, commit + push on drift. */
56
+ async function updateExisting(input: SidecarScaffoldInput): Promise<SidecarScaffoldResult> {
57
+ const { repo, dir } = input
58
+ if (!existsSync(resolve(dir, '.git'))) {
59
+ detail(`Cloning ${repo} → ${dir}`)
60
+ await run({ command: 'gh', args: ['repo', 'clone', repo, dir], cwd: process.cwd() })
61
+ } else {
62
+ detail(`Reusing existing checkout at ${dir}`)
63
+ }
64
+ await input.materialize(dir)
65
+ await run({ command: 'git', args: ['add', ...input.files], cwd: dir })
66
+ if (!(await hasStagedChanges(dir))) {
67
+ ok('Sidecar repository already up to date (no scaffold drift).')
68
+ return { dir, created: false }
69
+ }
70
+ await run({ command: 'git', args: ['commit', '-m', REFRESH_SCAFFOLD_COMMIT_MESSAGE], cwd: dir })
71
+ await run({ command: 'git', args: ['push'], cwd: dir })
72
+ ok('Sidecar repository scaffold updated + pushed.')
73
+ return { dir, created: false }
74
+ }
75
+
76
+ /** Repository does not exist: build a local checkout, commit the scaffold, create + push in one `gh` call. */
77
+ async function createFresh(input: SidecarScaffoldInput): Promise<SidecarScaffoldResult> {
78
+ const { repo, dir } = input
79
+ if (existsSync(resolve(dir, '.git'))) {
80
+ throw new Error(`${dir} is already a git repo but ${repo} does not exist on GitHub — resolve the mismatch by hand.`)
81
+ }
82
+ detail(`Creating a fresh checkout at ${dir}`)
83
+ await run({ command: 'git', args: ['init', '-b', 'main', dir], cwd: process.cwd() })
84
+ await input.materialize(dir)
85
+ await run({ command: 'git', args: ['add', ...input.files], cwd: dir })
86
+ await run({ command: 'git', args: ['commit', '-m', INITIAL_SCAFFOLD_COMMIT_MESSAGE], cwd: dir })
87
+ detail(`Creating ${repo} (private) and pushing`)
88
+ await run({
89
+ command: 'gh',
90
+ args: ['repo', 'create', repo, '--private', '--source', dir, '--remote', 'origin', '--push'],
91
+ cwd: process.cwd(),
92
+ })
93
+ ok(`Sidecar repository ${repo} created + pushed.`)
94
+ return { dir, created: true }
95
+ }
package/src/shell.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Shell-out helpers for the CLI, over `Bun.spawn`. Every orchestrated step (git, gh, the deploy
3
+ * trigger) goes through here so the rules are enforced in ONE place:
4
+ * - argv is passed verbatim (no shell) — nothing interpolated can be a metacharacter,
5
+ * - the child gets ONLY `process.env` + the explicit `env` the step needs,
6
+ * - on a non-zero exit we throw a SHORT message naming the command — never the child env, never a
7
+ * secret value, never the raw error object (which could carry a token-bearing URL).
8
+ */
9
+
10
+ /** A child step to run: the command + args, its cwd, and the extra env it needs (merged over process.env). */
11
+ export interface ShellStep {
12
+ command: string
13
+ args: string[]
14
+ cwd: string
15
+ /** Extra env vars for this child only. Secret VALUES live here and nowhere else. */
16
+ env?: Record<string, string>
17
+ /** Optional stdin written to the child (used for `gh secret set`, which reads the value from stdin). */
18
+ stdin?: string
19
+ }
20
+
21
+ /**
22
+ * Run a step, STREAMING its stdout/stderr to this terminal so the operator watches the underlying
23
+ * tool live (git/gh print progress). Throws on a non-zero exit. The thrown message names the command
24
+ * + exit code only.
25
+ */
26
+ export async function run(step: ShellStep): Promise<void> {
27
+ // stdout/stderr are PIPED (not the inherited TTY) and streamed back here. A child that probes the
28
+ // terminal — notably `gh`, which queries the background color with `ESC]11;?` (Go's termenv) on a
29
+ // TTY stdout — sees a NON-tty output and SKIPS the probe, so its query/response can't leak into a
30
+ // later interactive prompt (readline would otherwise read the stray `…;rgb:…` reply). The operator
31
+ // still watches output live; a child may drop ANSI colors since it no longer detects a TTY.
32
+ const proc = Bun.spawn([step.command, ...step.args], {
33
+ cwd: step.cwd,
34
+ env: { ...process.env, ...step.env },
35
+ stdin: step.stdin !== undefined ? new TextEncoder().encode(step.stdin) : 'inherit',
36
+ stdout: 'pipe',
37
+ stderr: 'pipe',
38
+ })
39
+ await Promise.all([
40
+ pump(proc.stdout, (chunk) => void process.stdout.write(chunk)),
41
+ pump(proc.stderr, (chunk) => void process.stderr.write(chunk)),
42
+ ])
43
+ const exitCode = await proc.exited
44
+ if (exitCode !== 0) {
45
+ throw new Error(`\`${step.command} ${step.args.join(' ')}\` failed (exit ${exitCode}).`)
46
+ }
47
+ }
48
+
49
+ /** Stream a child pipe to a sink, chunk by chunk, until it closes (a live tee that exposes no TTY). */
50
+ async function pump(src: ReadableStream<Uint8Array>, write: (chunk: Uint8Array) => void): Promise<void> {
51
+ const reader = src.getReader()
52
+ try {
53
+ for (;;) {
54
+ const { done, value } = await reader.read()
55
+ if (done) {
56
+ return
57
+ }
58
+ if (value !== undefined) {
59
+ write(value)
60
+ }
61
+ }
62
+ } finally {
63
+ reader.releaseLock()
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Run a step and CAPTURE its stdout (returned), while streaming stderr to this terminal. Used when we
69
+ * need to parse a tool's output (e.g. `gh repo view`). Throws on a non-zero exit; the captured stdout
70
+ * is NOT echoed by this helper.
71
+ */
72
+ export async function capture(step: ShellStep): Promise<string> {
73
+ const proc = Bun.spawn([step.command, ...step.args], {
74
+ cwd: step.cwd,
75
+ env: { ...process.env, ...step.env },
76
+ stdin: step.stdin !== undefined ? new TextEncoder().encode(step.stdin) : 'ignore',
77
+ stdout: 'pipe',
78
+ stderr: 'inherit',
79
+ })
80
+ const stdout = await new Response(proc.stdout).text()
81
+ const exitCode = await proc.exited
82
+ if (exitCode !== 0) {
83
+ throw new Error(`\`${step.command} ${step.args.join(' ')}\` failed (exit ${exitCode}).`)
84
+ }
85
+ return stdout
86
+ }
87
+
88
+ /** Run a step quietly: no stdout/stderr to the terminal, returns the exit code. For existence probes. */
89
+ export async function probe(step: ShellStep): Promise<number> {
90
+ const proc = Bun.spawn([step.command, ...step.args], {
91
+ cwd: step.cwd,
92
+ env: { ...process.env, ...step.env },
93
+ stdout: 'ignore',
94
+ stderr: 'ignore',
95
+ })
96
+ return proc.exited
97
+ }