@fabrika/installation-init 0.0.26 → 0.2.0

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.26",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "test": "bun test"
27
27
  },
28
28
  "dependencies": {
29
- "@fabrika/app": "^0.0.26",
30
- "@fabrika/github-app": "^0.0.26",
31
- "@fabrika/iam-contract": "^0.0.26"
29
+ "@fabrika/app": "^0.2.0",
30
+ "@fabrika/github-app": "^0.2.0",
31
+ "@fabrika/iam-contract": "^0.2.0"
32
32
  }
33
33
  }
@@ -2,11 +2,23 @@
2
2
  * Write the collected configuration into the base repo's GitHub **Environment** (not repo-level), so the
3
3
  * platform pipeline reads it per-environment. Secret VALUES go to `gh secret set --env` over STDIN (gh does
4
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.
5
+ * `gh variable set --env --body`. The environment itself is created (idempotent PUT) first, carrying the
6
+ * deployment-branch policy that is the only thing an Environment buys over a repository secret — a policy
7
+ * that restricts nothing until `main` itself carries a branch-protection rule.
6
8
  */
7
9
 
8
- import { ok, step, warn } from './log'
9
- import { capture, run } from './shell'
10
+ import { detail, ok, step, warn } from './log'
11
+ import {
12
+ DEPLOYMENT_BRANCH_POLICY,
13
+ environmentPolicyCommand,
14
+ environmentReviewerCommand,
15
+ type GhAttempt,
16
+ ghHttpStatus,
17
+ isPlanLimited,
18
+ type ProtectionOutcome,
19
+ unavailableReason,
20
+ } from './protection'
21
+ import { attempt, run, type ShellAttempt, type ShellStep } from './shell'
10
22
 
11
23
  /** A harmless cwd anchor — `gh` ignores cwd when `--repo` is given. */
12
24
  const ANCHOR = process.cwd()
@@ -18,29 +30,125 @@ export interface EnvironmentConfig {
18
30
  secrets: Record<string, string>
19
31
  /** Variable name → value (written via `gh variable set`). */
20
32
  vars: Record<string, string>
33
+ /**
34
+ * Told once whether only a protected branch may use this Environment's secrets, so a provider's
35
+ * closing summary can restate it. A callback rather than a return value: both providers already
36
+ * bind this function through a `Promise<void>` collaborator.
37
+ */
38
+ onProtection?(outcome: ProtectionOutcome): void
21
39
  }
22
40
 
41
+ /** The child processes `configureEnvironment` drives. Replaced wholesale in tests. */
42
+ export interface EnvironmentEffects {
43
+ attempt: GhAttempt
44
+ run(step: ShellStep): Promise<void>
45
+ }
46
+
47
+ const defaultEnvironmentEffects: EnvironmentEffects = { attempt, run }
48
+
23
49
  /** Create the GitHub Environment and populate its secrets + variables. */
24
- export async function configureEnvironment(config: EnvironmentConfig): Promise<void> {
50
+ export async function configureEnvironment(
51
+ config: EnvironmentConfig,
52
+ effects: EnvironmentEffects = defaultEnvironmentEffects,
53
+ ): Promise<void> {
25
54
  const { repo, environment, secrets, vars } = config
26
55
  step(`Configure the ${environment} GitHub Environment on ${repo}`)
27
56
 
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.`)
57
+ // Never `onProtection?.(await …)`: optional-call short-circuiting would skip creating the environment.
58
+ const protection = await createEnvironment(repo, environment, effects.attempt)
59
+ config.onProtection?.(protection)
31
60
 
32
61
  for (const [name, value] of Object.entries(secrets)) {
33
62
  if (value === '') {
34
63
  warn(`Skipping secret ${name} — no value (the workflow will fail without it; set it manually).`)
35
64
  continue
36
65
  }
37
- await run({ command: 'gh', args: ['secret', 'set', name, '--repo', repo, '--env', environment], cwd: ANCHOR, stdin: value })
66
+ await effects.run({ command: 'gh', args: ['secret', 'set', name, '--repo', repo, '--env', environment], cwd: ANCHOR, stdin: value })
38
67
  ok(`secret ${name} set`)
39
68
  }
40
69
  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 })
70
+ await effects.run({ command: 'gh', args: ['variable', 'set', name, '--repo', repo, '--env', environment, '--body', value], cwd: ANCHOR })
42
71
  ok(`variable ${name} set`)
43
72
  }
73
+ detail(`Recommended hardening — require your own approval before a deploy: ${environmentReviewerCommand(repo, environment)}`)
74
+ }
75
+
76
+ /**
77
+ * Idempotent create, carrying the deployment-branch policy MERGED over whatever protection rules the
78
+ * environment already has — a PUT replaces the whole object, so a reviewer or wait timer the operator
79
+ * added by hand would otherwise be dropped by the next `init`. A plan that does not offer protection
80
+ * rules refuses the body, so the environment is then created WITHOUT it rather than not at all; any
81
+ * other failure is a real error and is rethrown. Stdout is captured and discarded, never logged.
82
+ */
83
+ async function createEnvironment(repo: string, environment: string, gh: GhAttempt): Promise<ProtectionOutcome> {
84
+ const command = environmentPolicyCommand(repo, environment)
85
+ const path = `repos/${repo}/environments/${environment}`
86
+ const existing = await gh({ command: 'gh', args: ['api', path], cwd: ANCHOR })
87
+ if (existing.exitCode !== 0 && ghHttpStatus(existing) !== 404) {
88
+ warn(`Could not read ${environment}'s existing protection rules — a reviewer or wait timer set by hand may be reset by this write.`)
89
+ }
90
+ const body = JSON.stringify({ ...existingEnvironmentProtection(existing), deployment_branch_policy: DEPLOYMENT_BRANCH_POLICY })
91
+ const withPolicy = await gh({ command: 'gh', args: ['api', '-X', 'PUT', path, '--input', '-'], cwd: ANCHOR, stdin: body })
92
+ if (withPolicy.exitCode === 0) {
93
+ ok(`Environment ${environment} ready — deployment-branch policy set (it restricts nothing until main carries a protection rule).`)
94
+ return { applied: true, command }
95
+ }
96
+ if (!isPlanLimited(withPolicy)) {
97
+ throw new Error(`\`gh api -X PUT ${path}\` failed (${unavailableReason(withPolicy)}).`)
98
+ }
99
+ const reason = unavailableReason(withPolicy)
100
+ const plain = await gh({ command: 'gh', args: ['api', '-X', 'PUT', path], cwd: ANCHOR })
101
+ if (plain.exitCode !== 0) {
102
+ throw new Error(`\`gh api -X PUT ${path}\` failed (${unavailableReason(plain)}).`)
103
+ }
104
+ warn(`Environment ${environment} has NO deployment-branch policy (${reason}) — any branch that can run the workflow can read its secrets.`)
105
+ detail(`Apply it later: ${command}`)
106
+ return { applied: false, reason, command }
107
+ }
108
+
109
+ /** One reviewer in a PUT body: GitHub's GET nests the id under `reviewer`, its PUT wants it flat. */
110
+ interface EnvironmentReviewer {
111
+ readonly type: string
112
+ readonly id: number
113
+ }
114
+
115
+ /** The protection rules a PUT must carry forward, read off `gh api repos/…/environments/<name>`. */
116
+ export interface ExistingEnvironmentProtection {
117
+ readonly reviewers?: readonly EnvironmentReviewer[]
118
+ readonly wait_timer?: number
119
+ }
120
+
121
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
122
+
123
+ /**
124
+ * Translate a GET's `protection_rules` into the shape a PUT takes. Anything unrecognised is dropped:
125
+ * a body GitHub rejects would cost the deployment-branch policy, which is what this write is for.
126
+ */
127
+ export function existingEnvironmentProtection(result: ShellAttempt): ExistingEnvironmentProtection {
128
+ if (result.exitCode !== 0) return {}
129
+ let parsed: unknown
130
+ try {
131
+ parsed = JSON.parse(result.stdout)
132
+ } catch {
133
+ return {}
134
+ }
135
+ if (!isRecord(parsed) || !Array.isArray(parsed['protection_rules'])) return {}
136
+ const merged: { reviewers?: EnvironmentReviewer[]; wait_timer?: number } = {}
137
+ for (const rule of parsed['protection_rules']) {
138
+ if (!isRecord(rule)) continue
139
+ if (rule['type'] === 'wait_timer' && typeof rule['wait_timer'] === 'number') {
140
+ merged.wait_timer = rule['wait_timer']
141
+ }
142
+ if (rule['type'] === 'required_reviewers' && Array.isArray(rule['reviewers'])) {
143
+ const reviewers = rule['reviewers'].flatMap((entry: unknown): EnvironmentReviewer[] => {
144
+ if (!isRecord(entry) || typeof entry['type'] !== 'string' || !isRecord(entry['reviewer'])) return []
145
+ const id = entry['reviewer']['id']
146
+ return typeof id === 'number' ? [{ type: entry['type'], id }] : []
147
+ })
148
+ if (reviewers.length > 0) merged.reviewers = reviewers
149
+ }
150
+ }
151
+ return merged
44
152
  }
45
153
 
46
154
  /** Trigger the platform workflow (the real deploy runs in GitHub Actions). */
package/src/index.ts CHANGED
@@ -9,7 +9,14 @@ export {
9
9
  type IamAdminClient,
10
10
  type IamAdminConnection,
11
11
  } from './admin'
12
- export { configureEnvironment, type EnvironmentConfig, triggerPlatformWorkflow } from './environment'
12
+ export {
13
+ configureEnvironment,
14
+ type EnvironmentConfig,
15
+ type EnvironmentEffects,
16
+ type ExistingEnvironmentProtection,
17
+ existingEnvironmentProtection,
18
+ triggerPlatformWorkflow,
19
+ } from './environment'
13
20
  export { ghRepoExists, hasGhCli } from './gh'
14
21
  export {
15
22
  buildGitHubAppManifest,
@@ -23,11 +30,24 @@ export {
23
30
  } from './github-app-manifest'
24
31
  export { action, detail, fail, info, ok, step, url, warn } from './log'
25
32
  export { confirm, required, retry, secret, secretOrEnv, select, text } from './prompt'
33
+ export {
34
+ branchProtectionCommand,
35
+ DEPLOYMENT_BRANCH_POLICY,
36
+ environmentPolicyCommand,
37
+ environmentReviewerCommand,
38
+ type GhAttempt,
39
+ isPlanLimited,
40
+ PLAN_LIMITED,
41
+ probeSidecarBranchProtection,
42
+ type ProtectionOutcome,
43
+ protectSidecarBranch,
44
+ } from './protection'
26
45
  export {
27
46
  INITIAL_SCAFFOLD_COMMIT_MESSAGE,
28
47
  REFRESH_SCAFFOLD_COMMIT_MESSAGE,
29
48
  scaffoldSidecarRepository,
49
+ type SidecarScaffoldEffects,
30
50
  type SidecarScaffoldInput,
31
51
  type SidecarScaffoldResult,
32
52
  } from './scaffold'
33
- export { capture, probe, run, type ShellStep } from './shell'
53
+ export { attempt, capture, probe, run, type ShellAttempt, type ShellStep } from './shell'
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Harden the sidecar repository the operator owns. It holds the installation's entire credential set,
3
+ * so a push to `main` must not be able to rewrite the pipeline that reads them. ADR-0025 leaves the
4
+ * repository operator-owned; this only stops it from being weaker than the account it controls.
5
+ *
6
+ * GitHub Free offers neither rule on a PRIVATE repository, so a refusal is REPORTED, never fatal: the
7
+ * operator is told what is unprotected and the exact command that applies it later. Every body travels
8
+ * over stdin (`gh api --input -`), never on argv.
9
+ *
10
+ * An Environment's `protected_branches` policy is only as strong as the branch rule it points at: with no
11
+ * protection rule on `main`, the policy admits `main` and restricts nothing. The two are reported together
12
+ * for that reason.
13
+ */
14
+
15
+ import { detail, ok, warn } from './log'
16
+ import { attempt, type ShellAttempt, type ShellStep } from './shell'
17
+
18
+ /** A harmless cwd anchor — `gh` resolves the repository from the API path, not from cwd. */
19
+ const ANCHOR = process.cwd()
20
+
21
+ /** No force pushes, no deletions, admins included. No required review: `platform upgrade` pushes to `main`. */
22
+ export const BRANCH_PROTECTION_BODY = JSON.stringify({
23
+ required_status_checks: null,
24
+ enforce_admins: true,
25
+ required_pull_request_reviews: null,
26
+ restrictions: null,
27
+ allow_force_pushes: false,
28
+ allow_deletions: false,
29
+ })
30
+
31
+ /** Only a protected branch may use the Environment's secrets — and only where such a rule exists. */
32
+ export const DEPLOYMENT_BRANCH_POLICY = { protected_branches: true, custom_branch_policies: false } as const
33
+
34
+ /** The same policy as a standalone PUT body, for the command an operator runs by hand. */
35
+ export const DEPLOYMENT_BRANCH_POLICY_BODY = JSON.stringify({ deployment_branch_policy: DEPLOYMENT_BRANCH_POLICY })
36
+
37
+ /** Whether one hardening rule is in place, plus the command that applies it when it is not. */
38
+ export interface ProtectionOutcome {
39
+ readonly applied: boolean
40
+ /** One short, printable line saying why it is not applied. Absent when it is. */
41
+ readonly reason?: string
42
+ /** The `gh api` invocation that applies this rule later. */
43
+ readonly command: string
44
+ }
45
+
46
+ /** Run a `gh` step whose failure is an ANSWER rather than an error. The seam tests drive. */
47
+ export type GhAttempt = (step: ShellStep) => Promise<ShellAttempt>
48
+
49
+ export const branchProtectionCommand = (repo: string): string =>
50
+ `printf '%s' '${BRANCH_PROTECTION_BODY}' | gh api -X PUT repos/${repo}/branches/main/protection --input -`
51
+
52
+ export const environmentPolicyCommand = (repo: string, environment: string): string =>
53
+ `printf '%s' '${DEPLOYMENT_BRANCH_POLICY_BODY}' | gh api -X PUT repos/${repo}/environments/${environment} --input -`
54
+
55
+ /** Recommended, never applied by us: an approval click per deploy is the operator's call. */
56
+ export const environmentReviewerCommand = (repo: string, environment: string): string => {
57
+ const body = '{"reviewers":[{"type":"User","id":%s}],"deployment_branch_policy":{"protected_branches":true,"custom_branch_policies":false}}'
58
+ return `printf '${body}' "$(gh api user --jq .id)" | gh api -X PUT repos/${repo}/environments/${environment} --input -`
59
+ }
60
+
61
+ /** The HTTP status `gh` names in its error line (`gh: … (HTTP 403)`), when it names one. */
62
+ export const ghHttpStatus = (result: ShellAttempt): number | undefined => {
63
+ const status = /\(HTTP (\d{3})\)/.exec(result.stderr)?.[1]
64
+ return status === undefined ? undefined : Number(status)
65
+ }
66
+
67
+ /**
68
+ * The three statuses GitHub answers when a plan does not offer a protection rule. A 422 can also mean a
69
+ * malformed request, so this is a heuristic — it decides only whether we DEGRADE or give up, never
70
+ * whether something is safe.
71
+ */
72
+ export const PLAN_LIMITED: ReadonlySet<number> = new Set([403, 404, 422])
73
+
74
+ /** True when GitHub's answer is one this code is willing to continue without the rule on. */
75
+ export const isPlanLimited = (result: ShellAttempt): boolean => {
76
+ const status = ghHttpStatus(result)
77
+ return status !== undefined && PLAN_LIMITED.has(status)
78
+ }
79
+
80
+ /** One printable line for a refusal — the plan limit when GitHub named one, the exit code otherwise. */
81
+ export const unavailableReason = (result: ShellAttempt): string => {
82
+ const status = ghHttpStatus(result)
83
+ if (isPlanLimited(result)) {
84
+ return `HTTP ${String(status)} — not offered on this plan for a private repository`
85
+ }
86
+ return `\`gh\` exited ${result.exitCode}${status === undefined ? '' : ` (HTTP ${status})`}`
87
+ }
88
+
89
+ /** Protect `main` on the sidecar. Reports what is unprotected and never throws. */
90
+ export async function protectSidecarBranch(repo: string, gh: GhAttempt = attempt): Promise<ProtectionOutcome> {
91
+ const command = branchProtectionCommand(repo)
92
+ const result = await gh({
93
+ command: 'gh',
94
+ args: ['api', '-X', 'PUT', `repos/${repo}/branches/main/protection`, '--input', '-'],
95
+ cwd: ANCHOR,
96
+ stdin: BRANCH_PROTECTION_BODY,
97
+ })
98
+ if (result.exitCode === 0) {
99
+ ok('Branch protection on main: no force pushes, no deletions, enforced for admins.')
100
+ return { applied: true, command }
101
+ }
102
+ const reason = unavailableReason(result)
103
+ warn(`main is UNPROTECTED on ${repo} (${reason}) — nothing stops a force-push or a branch delete on main.`)
104
+ detail(`Apply it later: ${command}`)
105
+ return { applied: false, reason, command }
106
+ }
107
+
108
+ /**
109
+ * Read-only: does `main` already carry a protection rule? For a repository this run did NOT create — a
110
+ * PUT there would replace whatever the operator configured, so the existing rules are reported, never
111
+ * overwritten. A 404 is GitHub's answer for "no protection rule", which is exactly what we warn about.
112
+ */
113
+ export async function probeSidecarBranchProtection(repo: string, gh: GhAttempt = attempt): Promise<ProtectionOutcome> {
114
+ const command = branchProtectionCommand(repo)
115
+ const result = await gh({ command: 'gh', args: ['api', `repos/${repo}/branches/main/protection`], cwd: ANCHOR })
116
+ if (result.exitCode === 0) {
117
+ ok('Branch protection on main: already configured (left as the operator set it).')
118
+ return { applied: true, command }
119
+ }
120
+ const status = ghHttpStatus(result)
121
+ const reason = status === 404 ? 'HTTP 404 — no protection rule on main' : unavailableReason(result)
122
+ warn(`main is UNPROTECTED on ${repo} (${reason}) — nothing stops a force-push or a branch delete on main.`)
123
+ detail(`Apply it later: ${command}`)
124
+ return { applied: false, reason, command }
125
+ }
package/src/scaffold.ts CHANGED
@@ -7,13 +7,18 @@
7
7
  * WHAT the files are is the provider's answer, not this module's — `materialize` writes them and `files`
8
8
  * lists everything the scaffold ever stages. Scaffold commits carry `[skip ci]`, so a push here cannot
9
9
  * deploy anything before `init` has written the GitHub Environment the pipeline reads.
10
+ *
11
+ * A freshly created repository is protected before anything else is written to it. An EXISTING one is
12
+ * only PROBED, never PUT to: its protection is whatever its operator chose, and a blind PUT would replace
13
+ * stricter rules with ours. Either way the outcome is reported, so an unprotected sidecar is never silent.
10
14
  */
11
15
 
12
16
  import { existsSync } from 'node:fs'
13
17
  import { resolve } from 'node:path'
14
18
  import { ghRepoExists, hasGhCli } from './gh'
15
19
  import { detail, ok, step } from './log'
16
- import { probe, run } from './shell'
20
+ import { probeSidecarBranchProtection, type ProtectionOutcome, protectSidecarBranch } from './protection'
21
+ import { probe, run, type ShellStep } from './shell'
17
22
 
18
23
  export const INITIAL_SCAFFOLD_COMMIT_MESSAGE = 'chore: initial fabrika platform scaffold [skip ci]'
19
24
  export const REFRESH_SCAFFOLD_COMMIT_MESSAGE = 'chore: refresh fabrika platform scaffold [skip ci]'
@@ -33,63 +38,87 @@ export interface SidecarScaffoldInput {
33
38
  export interface SidecarScaffoldResult {
34
39
  readonly dir: string
35
40
  readonly created: boolean
41
+ /** Applied for a repository this run created, probed read-only for one that already existed. */
42
+ readonly branchProtection?: ProtectionOutcome
43
+ }
44
+
45
+ /** The child processes and GitHub calls the scaffold drives. Replaced wholesale in tests. */
46
+ export interface SidecarScaffoldEffects {
47
+ hasGhCli(): Promise<boolean>
48
+ repositoryExists(repo: string): Promise<boolean>
49
+ run(step: ShellStep): Promise<void>
50
+ probe(step: ShellStep): Promise<number>
51
+ protect(repo: string): Promise<ProtectionOutcome>
52
+ probeProtection(repo: string): Promise<ProtectionOutcome>
53
+ }
54
+
55
+ const defaultScaffoldEffects: SidecarScaffoldEffects = {
56
+ hasGhCli,
57
+ repositoryExists: ghRepoExists,
58
+ run,
59
+ probe,
60
+ protect: (repo) => protectSidecarBranch(repo),
61
+ probeProtection: (repo) => probeSidecarBranchProtection(repo),
36
62
  }
37
63
 
38
64
  /** 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
65
+ async function hasStagedChanges(effects: SidecarScaffoldEffects, dir: string): Promise<boolean> {
66
+ return (await effects.probe({ command: 'git', args: ['diff', '--cached', '--quiet'], cwd: dir })) !== 0
41
67
  }
42
68
 
43
69
  /**
44
70
  * Ensure the sidecar repository exists and carries the current scaffold. Returns the local checkout.
45
71
  * Requires `gh` authenticated with rights to create/administer the repository.
46
72
  */
47
- export async function scaffoldSidecarRepository(input: SidecarScaffoldInput): Promise<SidecarScaffoldResult> {
73
+ export async function scaffoldSidecarRepository(
74
+ input: SidecarScaffoldInput,
75
+ effects: SidecarScaffoldEffects = defaultScaffoldEffects,
76
+ ): Promise<SidecarScaffoldResult> {
48
77
  step(`Scaffold the sidecar repository (${input.repo})`)
49
- if (!(await hasGhCli())) {
78
+ if (!(await effects.hasGhCli())) {
50
79
  throw new Error('`gh` (GitHub CLI) is required — install it and run `gh auth login`.')
51
80
  }
52
- return (await ghRepoExists(input.repo)) ? updateExisting(input) : createFresh(input)
81
+ return (await effects.repositoryExists(input.repo)) ? updateExisting(input, effects) : createFresh(input, effects)
53
82
  }
54
83
 
55
84
  /** Repository already exists: clone if needed, refresh CLI-owned files, commit + push on drift. */
56
- async function updateExisting(input: SidecarScaffoldInput): Promise<SidecarScaffoldResult> {
85
+ async function updateExisting(input: SidecarScaffoldInput, effects: SidecarScaffoldEffects): Promise<SidecarScaffoldResult> {
57
86
  const { repo, dir } = input
58
87
  if (!existsSync(resolve(dir, '.git'))) {
59
88
  detail(`Cloning ${repo} → ${dir}`)
60
- await run({ command: 'gh', args: ['repo', 'clone', repo, dir], cwd: process.cwd() })
89
+ await effects.run({ command: 'gh', args: ['repo', 'clone', repo, dir], cwd: process.cwd() })
61
90
  } else {
62
91
  detail(`Reusing existing checkout at ${dir}`)
63
92
  }
64
93
  await input.materialize(dir)
65
- await run({ command: 'git', args: ['add', ...input.files], cwd: dir })
66
- if (!(await hasStagedChanges(dir))) {
94
+ await effects.run({ command: 'git', args: ['add', ...input.files], cwd: dir })
95
+ if (!(await hasStagedChanges(effects, dir))) {
67
96
  ok('Sidecar repository already up to date (no scaffold drift).')
68
- return { dir, created: false }
97
+ return { dir, created: false, branchProtection: await effects.probeProtection(repo) }
69
98
  }
70
- await run({ command: 'git', args: ['commit', '-m', REFRESH_SCAFFOLD_COMMIT_MESSAGE], cwd: dir })
71
- await run({ command: 'git', args: ['push'], cwd: dir })
99
+ await effects.run({ command: 'git', args: ['commit', '-m', REFRESH_SCAFFOLD_COMMIT_MESSAGE], cwd: dir })
100
+ await effects.run({ command: 'git', args: ['push'], cwd: dir })
72
101
  ok('Sidecar repository scaffold updated + pushed.')
73
- return { dir, created: false }
102
+ return { dir, created: false, branchProtection: await effects.probeProtection(repo) }
74
103
  }
75
104
 
76
105
  /** 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> {
106
+ async function createFresh(input: SidecarScaffoldInput, effects: SidecarScaffoldEffects): Promise<SidecarScaffoldResult> {
78
107
  const { repo, dir } = input
79
108
  if (existsSync(resolve(dir, '.git'))) {
80
109
  throw new Error(`${dir} is already a git repo but ${repo} does not exist on GitHub — resolve the mismatch by hand.`)
81
110
  }
82
111
  detail(`Creating a fresh checkout at ${dir}`)
83
- await run({ command: 'git', args: ['init', '-b', 'main', dir], cwd: process.cwd() })
112
+ await effects.run({ command: 'git', args: ['init', '-b', 'main', dir], cwd: process.cwd() })
84
113
  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 })
114
+ await effects.run({ command: 'git', args: ['add', ...input.files], cwd: dir })
115
+ await effects.run({ command: 'git', args: ['commit', '-m', INITIAL_SCAFFOLD_COMMIT_MESSAGE], cwd: dir })
87
116
  detail(`Creating ${repo} (private) and pushing`)
88
- await run({
117
+ await effects.run({
89
118
  command: 'gh',
90
119
  args: ['repo', 'create', repo, '--private', '--source', dir, '--remote', 'origin', '--push'],
91
120
  cwd: process.cwd(),
92
121
  })
93
122
  ok(`Sidecar repository ${repo} created + pushed.`)
94
- return { dir, created: true }
123
+ return { dir, created: true, branchProtection: await effects.protect(repo) }
95
124
  }
package/src/shell.ts CHANGED
@@ -106,3 +106,26 @@ export async function probe(step: ShellStep): Promise<number> {
106
106
  })
107
107
  return proc.exited
108
108
  }
109
+
110
+ /** What a step that is allowed to fail reported: its exit code plus everything it wrote. */
111
+ export interface ShellAttempt {
112
+ readonly exitCode: number
113
+ readonly stdout: string
114
+ readonly stderr: string
115
+ }
116
+
117
+ /**
118
+ * Run a step WITHOUT throwing, capturing both pipes. For a call whose failure is an ANSWER rather than
119
+ * an error — a `gh api` PUT that GitHub refuses because the plan does not offer the feature.
120
+ */
121
+ export async function attempt(step: ShellStep): Promise<ShellAttempt> {
122
+ const proc = Bun.spawn([step.command, ...step.args], {
123
+ cwd: step.cwd,
124
+ env: { ...process.env, ...step.env },
125
+ stdin: step.stdin !== undefined ? new TextEncoder().encode(step.stdin) : 'ignore',
126
+ stdout: 'pipe',
127
+ stderr: 'pipe',
128
+ })
129
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
130
+ return { exitCode: await proc.exited, stdout, stderr }
131
+ }