@biffo/cli 0.79.0 → 0.81.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.
@@ -67,15 +67,16 @@ jobs:
67
67
  - name: Test
68
68
  if: ${{ !cancelled() }}
69
69
  run: pnpm run test
70
- # Deliberately run with NO NEXT_PUBLIC_CORE_COGNITO_* in scope (issue
71
- # #286). `next build` prerenders `/`, which imports src/lib/auth.ts; when
72
- # that module built its Cognito pool eagerly the build blew up on any
73
- # machine without the real pool ids exported, and nothing in CI caught it
74
- # the first person to find out was whoever had just scaffolded the
75
- # sibling, debugging template code they did not write. A sibling's build
76
- # must not need auth credentials, so this step is the assertion of that.
77
- # Provide the real values in deploy.yml, where they are inlined into the
78
- # artifact that actually ships.
70
+ # Run with NO NEXT_PUBLIC_CORE_COGNITO_* in scope — this is now the
71
+ # PERMANENT state, not a build-guard quirk. The frontend NEVER reads those
72
+ # vars: it resolves the core's Cognito identity at runtime from
73
+ # /.well-known/biffo-identity.json (#403 Stage 3), so nothing is baked into
74
+ # the bundle and deploy.yml no longer sets them either. `next build`
75
+ # prerenders `/`, which imports src/lib/auth.ts; that module builds its
76
+ # Cognito pool lazily (never at module scope) so the build succeeds with no
77
+ # identity in scope. Originally added as a guard for the eager-construction
78
+ # crash (issue #286); it now simply asserts a sibling's build needs no auth
79
+ # credentials at all.
79
80
  - name: Build
80
81
  if: ${{ !cancelled() }}
81
82
  run: pnpm run build
@@ -153,8 +153,12 @@ jobs:
153
153
  NEXT_PUBLIC_SIBLING_PATH_PREFIX: ${{ vars.PATH_PREFIX && format('/{0}', vars.PATH_PREFIX) || '' }}
154
154
  NEXT_PUBLIC_BASE_PATH: ${{ vars.PATH_PREFIX && format('/{0}', vars.PATH_PREFIX) || '' }}
155
155
  NEXT_PUBLIC_CORE_PORTAL_URL: ${{ vars.CORE_PORTAL_URL }}
156
- NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID: ${{ vars.CORE_COGNITO_USER_POOL_ID }}
157
- NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID: ${{ vars.CORE_COGNITO_CLIENT_ID }}
156
+ # The frontend no longer bakes NEXT_PUBLIC_CORE_COGNITO_* into the
157
+ # bundle: it resolves the core's Cognito identity at RUNTIME from the
158
+ # core-published /.well-known/biffo-identity.json document (#403), so
159
+ # the build no longer needs the pool/client ids. (The BACKEND still
160
+ # consumes CORE_COGNITO_* via TF_VAR for JWT validation — see the
161
+ # deploy-infra job above; that is deliberately unchanged.)
158
162
  NEXT_PUBLIC_API_URL: ${{ vars.API_URL }}
159
163
  run: pnpm run build
160
164
  - uses: aws-actions/configure-aws-credentials@v6
@@ -198,14 +198,21 @@ part of the install, so its lifecycle scripts never run.)
198
198
 
199
199
  ## The build must not need Cognito credentials
200
200
 
201
- `pnpm run build` has to succeed with **no** `NEXT_PUBLIC_CORE_COGNITO_*` set,
202
- and `.github/workflows/ci.yml` runs it that way on every PR to keep it that
203
- way. `next build` prerenders `/` in Node, which imports `src/lib/auth.ts`; that
204
- module therefore constructs its Cognito user pool lazily, on first session
205
- read, never at module scope (the `CognitoUserPool` constructor throws outright
206
- when either id is missing). If you add module-scope code that requires real
207
- core config, the build breaks for everyone who has not exported the pool ids —
208
- starting with CI. Read config inside the function that needs it.
201
+ The frontend resolves the core's Cognito identity at **runtime** from the
202
+ core-published `/.well-known/biffo-identity.json` document (#403) it never
203
+ bakes `NEXT_PUBLIC_CORE_COGNITO_*` into the bundle. So `pnpm run build` succeeds
204
+ with **no** `NEXT_PUBLIC_CORE_COGNITO_*` set, and `.github/workflows/ci.yml`
205
+ runs it that way on every PR to keep it that way. `next build` prerenders `/` in
206
+ Node, which imports `src/lib/auth.ts`; that module constructs its Cognito user
207
+ pool lazily, on first session read, never at module scope (the `CognitoUserPool`
208
+ constructor throws outright when either id is missing). If you add module-scope
209
+ code that requires real core config, the build breaks for everyone — starting
210
+ with CI. Read config inside the function that needs it.
211
+
212
+ (The sibling's **backend** still receives the core pool/client ids at deploy
213
+ time via `TF_VAR_core_cognito_*` — its Terraform builds a JWKS URL from the pool
214
+ id to validate JWTs. That is a separate concern from the frontend bundle and is
215
+ deliberately unchanged.)
209
216
 
210
217
  ## The two-phase CDN registration
211
218
 
@@ -267,12 +274,19 @@ this repo's own `statusChecks` list — see that method's third parameter):
267
274
 
268
275
  1. Rename `biffo.sibling.json`'s `name`/`core_project`/`path_prefix`.
269
276
  2. Copy `apps/frontend/.env.example` to `.env.local` and fill in your core
270
- project's real Cognito/API values for local dev.
277
+ project's real API/portal values for local dev. No Cognito values are
278
+ needed: the frontend resolves the core's Cognito identity at runtime from
279
+ `/.well-known/biffo-identity.json` (#403). A locally-run frontend with no
280
+ reachable document just treats the visitor as signed-out.
271
281
  3. `cd infra && terraform init -backend-config=backend.hcl && terraform apply`
272
282
  (generate `backend.hcl` yourself for local use — CI generates it inline,
273
283
  see `deploy.yml`).
274
284
  4. Wire the GitHub Environment variables/secrets `deploy.yml` reads
275
285
  (`CORE_COGNITO_USER_POOL_ID`, `CORE_COGNITO_CLIENT_ID`, `CORE_API_URL`,
276
- `CORE_PORTAL_URL`, `SIBLING_OIDC_ROLE_ARN`, `TF_STATE_BUCKET`, etc.).
286
+ `CORE_PORTAL_URL`, `SIBLING_OIDC_ROLE_ARN`, `TF_STATE_BUCKET`, etc.). The
287
+ `CORE_COGNITO_*` variables are still required: the **backend** consumes them
288
+ via `TF_VAR_core_cognito_*` for JWT validation (its Terraform derives a JWKS
289
+ URL from the pool id). They are no longer inlined into the frontend bundle —
290
+ the frontend resolves the core's Cognito identity at runtime (#403).
277
291
  5. Open the registration PR against your core project (`infra/siblings.auto.tfvars.json`)
278
292
  yourself, or let `biffo sibling create` do it for you.
@@ -6,8 +6,11 @@ NEXT_PUBLIC_SIBLING_NAME=example-sibling
6
6
  NEXT_PUBLIC_SIBLING_PATH_PREFIX=/example-sibling
7
7
  NEXT_PUBLIC_BASE_PATH=/example-sibling
8
8
  NEXT_PUBLIC_CORE_PORTAL_URL=https://baseurl.com
9
- NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID=us-east-1_XXXXXXXXX
10
- NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID=XXXXXXXXXXXXXXXXXXXXXXXXXX
9
+ # No Cognito env is needed: the sibling resolves the core's Cognito identity at
10
+ # RUNTIME from /.well-known/biffo-identity.json (served by the core), so nothing
11
+ # is baked into the bundle (#403). A locally-run frontend with no reachable
12
+ # document simply treats the visitor as signed-out — a sibling never signs
13
+ # anyone in (ADR-0007).
11
14
  # This sibling's OWN API Gateway endpoint (from `terraform output api_url`),
12
15
  # NOT the core project's API — the frontend never calls the core API
13
16
  # directly (ADR-0002/ADR-0007).
@@ -22,16 +22,16 @@ function userWithSession(session: unknown, err: Error | null = null) {
22
22
  }
23
23
  }
24
24
 
25
- // The env vars are the TRANSITIONAL fallback (#403 Stage 3 removes them). The
26
- // document is now the preferred source, so most tests set env AND control
27
- // `fetch` to prove which one wins.
28
- function configureCognitoEnv() {
29
- vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', 'us-east-1_TESTPOOL')
30
- vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', 'testclientid')
25
+ // The sibling is DOC-ONLY (#403 Stage 3): it NEVER reads NEXT_PUBLIC_CORE_COGNITO_*.
26
+ // This helper only exists to set BOGUS env values in the "env is ignored" tests,
27
+ // proving the pool is never built from env even when it is present.
28
+ function stubBogusCognitoEnv() {
29
+ vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', 'us-east-1_BOGUSENV')
30
+ vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', 'bogusenvclientid')
31
31
  }
32
32
 
33
- // A `fetch` returning a valid identity document. The ids differ from the env
34
- // values on purpose so a test can prove the DOCUMENT wins over baked env.
33
+ // A `fetch` returning a valid identity document. The ids are what the pool must
34
+ // be built from the ONLY source of Cognito coordinates now.
35
35
  function mockFetchDocument(overrides: Record<string, unknown> = {}) {
36
36
  const body = {
37
37
  userPoolId: 'us-east-1_DOCPOOL',
@@ -45,7 +45,8 @@ function mockFetchDocument(overrides: Record<string, unknown> = {}) {
45
45
  }) as unknown as typeof fetch
46
46
  }
47
47
 
48
- // A `fetch` that rejects (network error) — drives the env fallback path.
48
+ // A `fetch` that rejects (network error) — the document is unreachable, so the
49
+ // sibling resolves to null (no env fallback).
49
50
  function mockFetchUnreachable() {
50
51
  global.fetch = vi.fn().mockRejectedValue(new Error('network down')) as unknown as typeof fetch
51
52
  }
@@ -53,7 +54,7 @@ function mockFetchUnreachable() {
53
54
  // Each test re-imports the module fresh: both the pool (auth.ts) and the
54
55
  // resolved-identity Promise (identity.ts) are memoised at module scope, so a
55
56
  // module instance left over from a previous test would carry that test's
56
- // document/env into the next one.
57
+ // document into the next one.
57
58
  async function loadAuth() {
58
59
  vi.resetModules()
59
60
  return await import('@/lib/auth')
@@ -62,11 +63,9 @@ async function loadAuth() {
62
63
  describe('getCurrentSession', () => {
63
64
  beforeEach(() => {
64
65
  vi.clearAllMocks()
65
- configureCognitoEnv()
66
- // Default: document unreachable, so these behaviour tests build the pool
67
- // from the env fallback (us-east-1_TESTPOOL / testclientid) exactly as the
68
- // pre-runtime version did.
69
- mockFetchUnreachable()
66
+ // The document is reachable, so these behaviour tests build the pool from
67
+ // the DOCUMENT (us-east-1_DOCPOOL / docclientid) never from env.
68
+ mockFetchDocument()
70
69
  })
71
70
 
72
71
  afterEach(() => {
@@ -100,9 +99,9 @@ describe('getCurrentSession', () => {
100
99
  })
101
100
  })
102
101
 
103
- // The core now publishes its Cognito coordinates at runtime; the sibling
104
- // prefers that document over its baked env vars (#403/#400). These tests pin
105
- // which source wins under each condition.
102
+ // The core publishes its Cognito coordinates at runtime; the sibling is
103
+ // DOC-ONLY (#403 Stage 3) and resolves them from that document alone. These
104
+ // tests pin that the document is the sole source env is never consulted.
106
105
  describe('runtime core identity resolution', () => {
107
106
  beforeEach(() => {
108
107
  vi.clearAllMocks()
@@ -113,9 +112,9 @@ describe('runtime core identity resolution', () => {
113
112
  vi.restoreAllMocks()
114
113
  })
115
114
 
116
- it('builds the pool from the published document, not from env', async () => {
117
- // Env is set to DIFFERENT values so a pass proves the document won.
118
- configureCognitoEnv()
115
+ it('builds the pool from the published document', async () => {
116
+ // Bogus env is set to prove the document not env — is the source.
117
+ stubBogusCognitoEnv()
119
118
  mockFetchDocument()
120
119
  const { getCurrentSession } = await loadAuth()
121
120
  getCurrentUser.mockReturnValue(null)
@@ -131,55 +130,44 @@ describe('runtime core identity resolution', () => {
131
130
  })
132
131
  })
133
132
 
134
- it('falls back to env and warns when the document is unreachable', async () => {
135
- configureCognitoEnv()
133
+ it('resolves null and never builds the pool from env when the document is unreachable', async () => {
134
+ // Env is present but BOGUS: a DOC-ONLY sibling must ignore it entirely.
135
+ stubBogusCognitoEnv()
136
136
  mockFetchUnreachable()
137
137
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
138
138
  const { getCurrentSession } = await loadAuth()
139
139
  getCurrentUser.mockReturnValue(null)
140
140
 
141
- await getCurrentSession()
142
-
143
- expect(poolConstructor).toHaveBeenCalledWith({
144
- UserPoolId: 'us-east-1_TESTPOOL',
145
- ClientId: 'testclientid',
146
- })
141
+ await expect(getCurrentSession()).resolves.toBeNull()
142
+ // Proves env is ignored: the pool was never constructed from the bogus env.
143
+ expect(poolConstructor).not.toHaveBeenCalled()
147
144
  expect(warn).toHaveBeenCalledOnce()
148
- expect(warn.mock.calls[0]?.[0]).toContain('DEGRADED')
149
145
  })
150
146
 
151
- it('falls back to env when the document is served but missing ids', async () => {
152
- configureCognitoEnv()
147
+ it('resolves null and never builds the pool when the document is served but missing ids', async () => {
148
+ stubBogusCognitoEnv()
153
149
  // ok:true but the body lacks a clientId — treated as unusable.
154
150
  mockFetchDocument({ clientId: '' })
155
151
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
156
152
  const { getCurrentSession } = await loadAuth()
157
153
  getCurrentUser.mockReturnValue(null)
158
154
 
159
- await getCurrentSession()
160
-
161
- expect(poolConstructor).toHaveBeenCalledWith({
162
- UserPoolId: 'us-east-1_TESTPOOL',
163
- ClientId: 'testclientid',
164
- })
155
+ await expect(getCurrentSession()).resolves.toBeNull()
156
+ expect(poolConstructor).not.toHaveBeenCalled()
165
157
  expect(warn).toHaveBeenCalledOnce()
166
158
  })
167
159
 
168
- it('resolves null and never constructs the pool when both document and env are absent', async () => {
169
- vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', '')
170
- vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', '')
160
+ it('resolves null when the document is unreachable', async () => {
171
161
  mockFetchUnreachable()
172
162
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
173
163
  const { getCurrentSession } = await loadAuth()
174
164
 
175
165
  await expect(getCurrentSession()).resolves.toBeNull()
176
166
  expect(poolConstructor).not.toHaveBeenCalled()
177
- // No document AND no env is an unconfigured build, not degradation — quiet.
178
- expect(warn).not.toHaveBeenCalled()
167
+ expect(warn).toHaveBeenCalledOnce()
179
168
  })
180
169
 
181
170
  it('fetches the document at most once across multiple session reads (memoised)', async () => {
182
- configureCognitoEnv()
183
171
  mockFetchDocument()
184
172
  const { getCurrentSession } = await loadAuth()
185
173
  getCurrentUser.mockReturnValue(null)
@@ -210,14 +198,12 @@ describe('lazy pool construction', () => {
210
198
  })
211
199
 
212
200
  it('does not construct the pool merely by importing the module', async () => {
213
- configureCognitoEnv()
201
+ mockFetchDocument()
214
202
  await loadAuth()
215
203
  expect(poolConstructor).not.toHaveBeenCalled()
216
204
  })
217
205
 
218
- it('imports cleanly with no Cognito env vars set and no fetch available', async () => {
219
- vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', '')
220
- vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', '')
206
+ it('imports cleanly with no fetch available (next build prerender)', async () => {
221
207
  // Simulate `next build` prerendering `/` in Node with no fetch in scope.
222
208
  const originalFetch = global.fetch
223
209
  // @ts-expect-error deliberately remove fetch to mimic the Node prerender.
@@ -230,16 +216,14 @@ describe('lazy pool construction', () => {
230
216
  }
231
217
  })
232
218
 
233
- it('resolves null instead of throwing when Cognito env vars are absent', async () => {
234
- vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', '')
235
- vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', '')
219
+ it('resolves null instead of throwing when the document is unreachable', async () => {
236
220
  const { getCurrentSession } = await loadAuth()
237
221
  await expect(getCurrentSession()).resolves.toBeNull()
238
222
  expect(poolConstructor).not.toHaveBeenCalled()
239
223
  })
240
224
 
241
- it('constructs the pool once, on first session read, from the env fallback', async () => {
242
- configureCognitoEnv()
225
+ it('constructs the pool once, on first session read, from the document', async () => {
226
+ mockFetchDocument()
243
227
  const { getCurrentSession } = await loadAuth()
244
228
  getCurrentUser.mockReturnValue(null)
245
229
 
@@ -248,8 +232,8 @@ describe('lazy pool construction', () => {
248
232
 
249
233
  expect(poolConstructor).toHaveBeenCalledTimes(1)
250
234
  expect(poolConstructor).toHaveBeenCalledWith({
251
- UserPoolId: 'us-east-1_TESTPOOL',
252
- ClientId: 'testclientid',
235
+ UserPoolId: 'us-east-1_DOCPOOL',
236
+ ClientId: 'docclientid',
253
237
  })
254
238
  })
255
239
  })
@@ -32,11 +32,12 @@ import { resolveCoreIdentity } from './identity'
32
32
  // ---------------------------------------------------------------------------
33
33
  // Module-private on purpose: the pool is an implementation detail of
34
34
  // getCurrentSession(), not part of this sibling's auth surface. Its Cognito
35
- // coordinates now come from resolveCoreIdentity() (identity.ts), which prefers
36
- // the core's runtime-published /.well-known/biffo-identity.json document and
37
- // only falls back to the baked NEXT_PUBLIC_CORE_COGNITO_* env vars when that
38
- // document is unreachable see identity.ts for why runtime resolution kills
39
- // the stale-baked-pool bug class (#403/#400).
35
+ // coordinates come from resolveCoreIdentity() (identity.ts), which resolves the
36
+ // core's runtime-published /.well-known/biffo-identity.json document. The
37
+ // sibling is now DOC-ONLY (#403 Stage 3): there is NO NEXT_PUBLIC_CORE_COGNITO_*
38
+ // env fallback. An unreachable document resolves to null the caller treats it
39
+ // as "signed out" see identity.ts for why runtime resolution kills the
40
+ // stale-baked-pool bug class (#403/#400).
40
41
  //
41
42
  // Constructed LAZILY, on first session read — never at module scope. The
42
43
  // CognitoUserPool constructor throws ("Both UserPoolId and ClientId are
@@ -29,12 +29,13 @@
29
29
  // this exactly ONE network request per page load, shared by every caller,
30
30
  // rather than one per session read.
31
31
  //
32
- // The env fallback below is TRANSITIONAL (Stage 3 of #403 removes it). It lets
33
- // an instance whose core has not yet started publishing the document keep
34
- // working off its baked env vars, so instances can upgrade at their own pace.
35
- // A baked value that shadows a live document is precisely the stale-copy bug
36
- // we are removing, so falling back because the document was UNREACHABLE is a
37
- // degraded path and warns loudly.
32
+ // A sibling is DOC-ONLY (#403 Stage 3): it carries NO CORE_COGNITO_* env vars —
33
+ // `biffo sibling create` no longer sets them and this module no longer reads
34
+ // them. When the document is unreachable, resolution returns null, and the
35
+ // caller (getCurrentSession) treats that as "no session" the sibling redirects
36
+ // to the portal's login rather than trusting a stale baked value, because a
37
+ // stale value is exactly the bug (#400) this removes. A clean redirect beats a
38
+ // silent point at a dead pool.
38
39
  // ---------------------------------------------------------------------------
39
40
 
40
41
  export interface CoreIdentity {
@@ -54,16 +55,6 @@ const IDENTITY_DOCUMENT_PATH = '/.well-known/biffo-identity.json'
54
55
  // callers before the fetch settles also coalesce onto the one request.
55
56
  let cached: Promise<CoreIdentity | null> | null = null
56
57
 
57
- // Read the transitional env fallback. Returns a valid CoreIdentity only when
58
- // BOTH ids are present; otherwise null (an unconfigured build has neither a
59
- // document nor env, and that is not an error to shout about).
60
- function identityFromEnv(): CoreIdentity | null {
61
- const userPoolId = process.env['NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID'] ?? ''
62
- const clientId = process.env['NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID'] ?? ''
63
- if (!userPoolId || !clientId) return null
64
- return { userPoolId, clientId }
65
- }
66
-
67
58
  // A parsed document counts only when it carries both ids non-empty; a document
68
59
  // missing either is treated as unusable and triggers the fallback.
69
60
  function identityFromDocument(data: unknown): CoreIdentity | null {
@@ -90,38 +81,31 @@ async function fetchCoreIdentity(): Promise<CoreIdentity | null> {
90
81
  const identity = identityFromDocument(await res.json())
91
82
  if (identity) return identity
92
83
  }
93
- // Reached here => the document was served but unusable (non-ok status or
94
- // missing ids). Fall through to the degraded env fallback below.
84
+ // Served but unusable (non-ok status or missing ids). Fall through to null.
95
85
  } catch {
96
86
  // Network error, or `fetch` not available (e.g. `next build` prerendering
97
- // `/` in Node with no fetch). Fall through to the env fallback.
87
+ // in Node with no fetch though resolution is lazy and never runs there).
88
+ // Fall through to null.
98
89
  }
99
90
 
100
- // DEGRADED path: the document was unreachable/unusable, so we lean on the
101
- // baked env vars. If those exist, warn once a stale baked value silently
102
- // shadowing a live document is the #403/#400 bug. If they DON'T exist, this
103
- // is simply an unconfigured build (no document, no env) not degradation,
104
- // so stay quiet.
105
- const fallback = identityFromEnv()
106
- if (fallback) {
107
- console.warn(
108
- '[biffo] DEGRADED: could not resolve the core identity document at ' +
109
- `${IDENTITY_DOCUMENT_PATH}; falling back to baked ` +
110
- 'NEXT_PUBLIC_CORE_COGNITO_* env vars. This is transitional (#403) and ' +
111
- 'risks pointing at a stale/dead Cognito pool — ensure the core is ' +
112
- 'publishing the runtime identity document.',
113
- )
114
- }
115
- return fallback
91
+ // The document was unreachable or unusable. A sibling has NO baked fallback
92
+ // (#403 Stage 3), so this resolves to null and the caller treats it as
93
+ // "signed out" — a clean redirect to the portal login beats trusting a stale
94
+ // local pool id, which is the exact bug (#400) this removes.
95
+ console.warn(
96
+ `[biffo] could not resolve the core identity document at ${IDENTITY_DOCUMENT_PATH}; ` +
97
+ 'treating the visitor as signed out. If this persists, confirm the core is publishing it.',
98
+ )
99
+ return null
116
100
  }
117
101
 
118
102
  /**
119
- * Resolve the core's Cognito identity at runtime, preferring the published
120
- * `/.well-known/biffo-identity.json` document and falling back to baked
121
- * `NEXT_PUBLIC_CORE_COGNITO_*` env vars when the document is unreachable.
103
+ * Resolve the core's Cognito identity at runtime from the published
104
+ * `/.well-known/biffo-identity.json` document. A sibling has no env fallback
105
+ * (#403 Stage 3).
122
106
  *
123
107
  * Memoised: at most one fetch per page load, shared by every caller. Returns
124
- * null when neither the document nor the env vars supply both ids.
108
+ * null when the document is unreachable or does not supply both ids.
125
109
  */
126
110
  export function resolveCoreIdentity(): Promise<CoreIdentity | null> {
127
111
  cached ??= fetchCoreIdentity()
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command24 } from "commander";
4
+ import { Command as Command25 } from "commander";
5
5
 
6
6
  // src/commands/core.ts
7
7
  import { Command as Command4 } from "commander";
@@ -819,7 +819,7 @@ var GitHubAdapter = class {
819
819
  } catch (err) {
820
820
  if (err.status !== 404) throw err;
821
821
  }
822
- await new Promise((resolve17) => setTimeout(resolve17, intervalMs));
822
+ await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
823
823
  }
824
824
  throw new Error(
825
825
  `Branch "${branch}" not found in ${org}/${repo} after ${timeoutMs / 1e3}s \u2014 GitHub template generation may have stalled. Check the repository and re-run biffo init.`
@@ -834,7 +834,7 @@ var GitHubAdapter = class {
834
834
  } catch (err) {
835
835
  if (err.status !== 404) throw err;
836
836
  }
837
- await new Promise((resolve17) => setTimeout(resolve17, intervalMs));
837
+ await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
838
838
  }
839
839
  throw new Error(
840
840
  `Ref "${ref}" not found in ${org}/${repo} after ${timeoutMs / 1e3}s \u2014 GitHub template generation may have stalled. Check the repository and re-run biffo init.`
@@ -1039,7 +1039,7 @@ var GitHubAdapter = class {
1039
1039
  }
1040
1040
  if (status !== 404 || Date.now() >= deadline) throw err;
1041
1041
  log.info("Branch protection endpoint not yet ready, retrying...");
1042
- await new Promise((resolve17) => setTimeout(resolve17, protectionIntervalMs));
1042
+ await new Promise((resolve18) => setTimeout(resolve18, protectionIntervalMs));
1043
1043
  }
1044
1044
  }
1045
1045
  }
@@ -1082,6 +1082,28 @@ var GitHubAdapter = class {
1082
1082
  }
1083
1083
  }
1084
1084
  }
1085
+ /**
1086
+ * Read an environment-scoped Actions variable's value, or `null` if it isn't
1087
+ * set (a 404). The counterpart to `setEnvVariable`, used by
1088
+ * `biffo sibling check-identity` to read each sibling's baked-in
1089
+ * `CORE_COGNITO_USER_POOL_ID` and compare it against the core's live pool
1090
+ * (#400/#496). A 404 means the variable was never set on that environment,
1091
+ * which is a real, reportable state — not an error — so it maps to `null`.
1092
+ */
1093
+ async getEnvVariable(org, repo, env, name) {
1094
+ try {
1095
+ const { data } = await this.octokit.request(
1096
+ "GET /repos/{owner}/{repo}/environments/{environment_name}/variables/{variable_name}",
1097
+ { owner: org, repo, environment_name: env, variable_name: name }
1098
+ );
1099
+ return data.value;
1100
+ } catch (err) {
1101
+ if (err.status === 404) {
1102
+ return null;
1103
+ }
1104
+ throw err;
1105
+ }
1106
+ }
1085
1107
  async setRepoVariable(org, repo, name, value) {
1086
1108
  log.info(`Setting variable: ${name}`);
1087
1109
  try {
@@ -1243,7 +1265,7 @@ var GitHubAdapter = class {
1243
1265
  } catch (err) {
1244
1266
  if (err.status !== 404 || Date.now() >= deadline) throw err;
1245
1267
  log.info(`Workflow ${workflowId} not yet indexed by GitHub Actions, retrying...`);
1246
- await new Promise((resolve17) => setTimeout(resolve17, intervalMs));
1268
+ await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
1247
1269
  }
1248
1270
  }
1249
1271
  }
@@ -1262,7 +1284,7 @@ var GitHubAdapter = class {
1262
1284
  } catch (err) {
1263
1285
  if (err.status !== 404 || Date.now() >= deadline) throw err;
1264
1286
  log.info(`Workflow ${workflowId} not yet indexed by GitHub Actions, retrying...`);
1265
- await new Promise((resolve17) => setTimeout(resolve17, intervalMs));
1287
+ await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
1266
1288
  }
1267
1289
  }
1268
1290
  }
@@ -1286,7 +1308,7 @@ var GitHubAdapter = class {
1286
1308
  } else {
1287
1309
  log.info(" Waiting for run to be queued...");
1288
1310
  }
1289
- await new Promise((resolve17) => setTimeout(resolve17, intervalMs));
1311
+ await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
1290
1312
  }
1291
1313
  throw new Error(
1292
1314
  `Workflow ${workflowId} did not complete within ${timeoutMs / 1e3 / 60} minutes`
@@ -2831,7 +2853,7 @@ var AwsAdapter = class {
2831
2853
  const code = err.Code;
2832
2854
  if (code === "OperationAborted" && attempt < maxAttempts) {
2833
2855
  log.info(` Waiting for S3 to release "${bucketName}"... (${attempt}/${maxAttempts})`);
2834
- await new Promise((resolve17) => setTimeout(resolve17, retryDelayMs));
2856
+ await new Promise((resolve18) => setTimeout(resolve18, retryDelayMs));
2835
2857
  } else if (code === "OperationAborted") {
2836
2858
  return false;
2837
2859
  } else {
@@ -7258,14 +7280,268 @@ pluginCommand.addCommand(pluginSyncMigrationsCommand);
7258
7280
  pluginCommand.addCommand(pluginInfoCommand);
7259
7281
 
7260
7282
  // src/commands/sibling.ts
7283
+ import { Command as Command22 } from "commander";
7284
+
7285
+ // src/commands/sibling-check-identity.ts
7286
+ import { existsSync as existsSync29, readFileSync as readFileSync22 } from "fs";
7287
+ import { resolve as resolve17 } from "path";
7288
+ import chalk20 from "chalk";
7261
7289
  import { Command as Command21 } from "commander";
7262
- var siblingCommand = new Command21("sibling").description(
7290
+
7291
+ // src/lib/sibling-identity-check.ts
7292
+ function checkSiblingIdentity(envs) {
7293
+ const findings = [];
7294
+ for (const env of envs) {
7295
+ if (env.publishedDoc === null) {
7296
+ findings.push({
7297
+ environment: env.environment,
7298
+ subject: "published-document",
7299
+ kind: "published-doc-unreachable",
7300
+ expected: env.livePoolId,
7301
+ actual: null
7302
+ });
7303
+ } else {
7304
+ const docPool = env.publishedDoc.userPoolId ?? null;
7305
+ if (docPool !== env.livePoolId) {
7306
+ findings.push({
7307
+ environment: env.environment,
7308
+ subject: "published-document",
7309
+ kind: "published-doc-stale",
7310
+ expected: env.livePoolId,
7311
+ actual: docPool
7312
+ });
7313
+ }
7314
+ }
7315
+ for (const sibling of env.siblings) {
7316
+ if (sibling.coreCognitoUserPoolId === null) {
7317
+ findings.push({
7318
+ environment: env.environment,
7319
+ subject: sibling.projectName,
7320
+ kind: "sibling-var-missing",
7321
+ expected: env.livePoolId,
7322
+ actual: null
7323
+ });
7324
+ } else if (sibling.coreCognitoUserPoolId !== env.livePoolId) {
7325
+ findings.push({
7326
+ environment: env.environment,
7327
+ subject: sibling.projectName,
7328
+ kind: "sibling-backend-stale",
7329
+ expected: env.livePoolId,
7330
+ actual: sibling.coreCognitoUserPoolId
7331
+ });
7332
+ }
7333
+ }
7334
+ }
7335
+ return { ok: findings.length === 0, findings };
7336
+ }
7337
+
7338
+ // src/commands/sibling-check-identity.ts
7339
+ var VALID_ENVIRONMENTS2 = ["dev", "staging", "prod"];
7340
+ var SIBLING_CORE_POOL_VAR = "CORE_COGNITO_USER_POOL_ID";
7341
+ var siblingCheckIdentityCommand = new Command21("check-identity").description(
7342
+ "Detect when a core's Cognito pool has drifted from its published identity document or any sibling's baked-in CORE_COGNITO_USER_POOL_ID (#400). Run from the core repo; exits non-zero on drift so a scheduled/CI run goes red."
7343
+ ).option("--env <environment>", "Only check this environment (default: dev, staging, prod)").option("-p, --project <name>", "Project name (overrides biffo.config.json in current directory)").option("-c, --config <path>", "Path to biffo.config.json").action(async (options) => {
7344
+ if (options.env && !VALID_ENVIRONMENTS2.includes(options.env)) {
7345
+ log.error(
7346
+ `Unknown environment: ${options.env}. Must be one of: ${VALID_ENVIRONMENTS2.join(", ")}`
7347
+ );
7348
+ process.exit(1);
7349
+ }
7350
+ const environments = options.env ? [options.env] : [...VALID_ENVIRONMENTS2];
7351
+ const config = await resolveConfig4(options);
7352
+ const { org, repo } = config.source_control.config;
7353
+ const awsConfig2 = config.cloud.config;
7354
+ const stateBucket = awsConfig2.tf_state_bucket ?? `${config.project.name}-terraform-state-${awsConfig2.account_id}`;
7355
+ const token = await resolveGithubToken3(true);
7356
+ const deps = {
7357
+ aws: new AwsAdapter(config),
7358
+ github: new GitHubAdapter(token),
7359
+ fetchIdentityDoc: fetchPublishedIdentity
7360
+ };
7361
+ console.log(chalk20.bold(`
7362
+ Biffo \u2014 Sibling identity check (${config.project.name})
7363
+ `));
7364
+ let result;
7365
+ try {
7366
+ result = await runCheckIdentity(deps, {
7367
+ coreOrg: org,
7368
+ coreRepo: repo,
7369
+ coreProjectName: config.project.name,
7370
+ stateBucket,
7371
+ environments
7372
+ });
7373
+ } catch (err) {
7374
+ if (err instanceof SiblingResolutionError) {
7375
+ log.error(`Could not enumerate siblings: ${err.message}`);
7376
+ } else {
7377
+ log.error(`Identity check failed: ${err.message}`);
7378
+ }
7379
+ process.exit(1);
7380
+ }
7381
+ printIdentityReport(result);
7382
+ if (!result.ok) process.exit(1);
7383
+ });
7384
+ async function runCheckIdentity(deps, params) {
7385
+ const siblings = await discoverSiblings(
7386
+ deps.github,
7387
+ params.coreOrg,
7388
+ params.coreRepo,
7389
+ params.coreProjectName
7390
+ );
7391
+ const envInputs = [];
7392
+ const skipped = [];
7393
+ for (const environment of params.environments) {
7394
+ let outputs;
7395
+ try {
7396
+ outputs = await deps.aws.readTerraformOutputs(
7397
+ params.stateBucket,
7398
+ `${environment}/terraform.tfstate`
7399
+ );
7400
+ } catch {
7401
+ skipped.push({
7402
+ environment,
7403
+ reason: "no Terraform state (core not deployed to this environment)"
7404
+ });
7405
+ continue;
7406
+ }
7407
+ const livePoolId = outputs["cognito_user_pool_id"];
7408
+ const portalUrl = outputs["portal_url"];
7409
+ if (!livePoolId || !portalUrl) {
7410
+ skipped.push({
7411
+ environment,
7412
+ reason: "Terraform outputs missing cognito_user_pool_id or portal_url"
7413
+ });
7414
+ continue;
7415
+ }
7416
+ const publishedDoc = await deps.fetchIdentityDoc(portalUrl);
7417
+ const envSiblings = siblings.filter(
7418
+ (s) => s.registered && s.repoState !== "gone" && s.environments.includes(environment)
7419
+ );
7420
+ const siblingInputs = await Promise.all(
7421
+ envSiblings.map(async (s) => ({
7422
+ projectName: s.projectName,
7423
+ coreCognitoUserPoolId: await deps.github.getEnvVariable(
7424
+ s.org,
7425
+ s.repo,
7426
+ environment,
7427
+ SIBLING_CORE_POOL_VAR
7428
+ )
7429
+ }))
7430
+ );
7431
+ envInputs.push({ environment, livePoolId, publishedDoc, siblings: siblingInputs });
7432
+ }
7433
+ const { ok, findings } = checkSiblingIdentity(envInputs);
7434
+ return { ok, findings, skipped };
7435
+ }
7436
+ var FINDING_LABEL = {
7437
+ "published-doc-unreachable": "published identity document unreachable",
7438
+ "published-doc-stale": "published identity document is stale",
7439
+ "sibling-var-missing": `sibling backend has no ${SIBLING_CORE_POOL_VAR}`,
7440
+ "sibling-backend-stale": `sibling backend ${SIBLING_CORE_POOL_VAR} is stale`
7441
+ };
7442
+ function printIdentityReport(result) {
7443
+ for (const s of result.skipped) {
7444
+ log.warn(`${s.environment}: skipped \u2014 ${s.reason}`);
7445
+ }
7446
+ if (result.ok) {
7447
+ log.success(
7448
+ chalk20.green(
7449
+ "\u2713 identity consistent \u2014 every published document and sibling backend matches the live pool"
7450
+ )
7451
+ );
7452
+ return;
7453
+ }
7454
+ log.error(chalk20.red(`\u2718 ${String(result.findings.length)} identity drift finding(s):`));
7455
+ for (const f of result.findings) {
7456
+ console.error(
7457
+ chalk20.red(
7458
+ ` [${f.environment}] ${f.subject}: ${FINDING_LABEL[f.kind]}
7459
+ expected (live pool): ${f.expected}
7460
+ found: ${f.actual ?? "(unset)"}`
7461
+ )
7462
+ );
7463
+ }
7464
+ }
7465
+ async function fetchPublishedIdentity(portalUrl) {
7466
+ try {
7467
+ const base = portalUrl.replace(/\/+$/, "");
7468
+ const res = await fetch(`${base}/.well-known/biffo-identity.json`, {
7469
+ cache: "no-store"
7470
+ });
7471
+ if (!res.ok) return null;
7472
+ const data = await res.json();
7473
+ return { userPoolId: data?.userPoolId ?? null };
7474
+ } catch {
7475
+ return null;
7476
+ }
7477
+ }
7478
+ async function resolveConfig4(options) {
7479
+ if (options.config) {
7480
+ const raw = JSON.parse(readFileSync22(resolve17(options.config), "utf8"));
7481
+ const result = BiffoConfigSchema.safeParse(raw);
7482
+ if (!result.success) {
7483
+ log.error(`Invalid config at ${options.config}:`);
7484
+ result.error.issues.forEach((i) => log.error(` ${i.path.join(".")} \u2014 ${i.message}`));
7485
+ process.exit(1);
7486
+ }
7487
+ return result.data;
7488
+ }
7489
+ if (options.project) {
7490
+ const cfg = loadProjectConfig(options.project);
7491
+ if (!cfg) {
7492
+ log.error(
7493
+ `Project "${options.project}" not found in ~/.biffo/projects/. Run biffo init first or pass --config <path>.`
7494
+ );
7495
+ process.exit(1);
7496
+ }
7497
+ return cfg;
7498
+ }
7499
+ const localConfigPath = resolve17(process.cwd(), "biffo.config.json");
7500
+ if (existsSync29(localConfigPath)) {
7501
+ const raw = JSON.parse(readFileSync22(localConfigPath, "utf8"));
7502
+ const result = BiffoConfigSchema.safeParse(raw);
7503
+ if (result.success) return result.data;
7504
+ if (isTemplatePlaceholderConfig(raw)) {
7505
+ log.warn(
7506
+ `Ignoring ${localConfigPath} \u2014 it is the unsubstituted Biffo template placeholder, not this project's config.`
7507
+ );
7508
+ } else {
7509
+ log.error(`Invalid config at ${localConfigPath}:`);
7510
+ result.error.issues.forEach((i) => log.error(` ${i.path.join(".")} \u2014 ${i.message}`));
7511
+ log.error("Refusing to fall back to a saved project while a local config file is present.");
7512
+ log.error("Fix biffo.config.json, or pass --project <name> / --config <path> explicitly.");
7513
+ process.exit(1);
7514
+ }
7515
+ }
7516
+ const projects = listProjectConfigs();
7517
+ if (projects.length === 0) {
7518
+ log.error(
7519
+ "No biffo.config.json found in the current directory and no projects in ~/.biffo/projects/."
7520
+ );
7521
+ log.error("Run biffo init first, or pass --project <name> or --config <path>.");
7522
+ log.error(
7523
+ "A Biffo instance's resolved config is not committed to its repo \u2014 it lives in ~/.biffo/projects/ on the machine that ran biffo init. On a second machine, copy it there or pass it with --config <path>."
7524
+ );
7525
+ process.exit(1);
7526
+ }
7527
+ if (projects.length === 1) {
7528
+ log.info(`Using project: ${projects[0].project.name}`);
7529
+ return projects[0];
7530
+ }
7531
+ log.error("Multiple projects found in ~/.biffo/projects/. Pass --project <name> to choose one:");
7532
+ for (const p of projects) log.error(` ${p.project.name}`);
7533
+ process.exit(1);
7534
+ }
7535
+
7536
+ // src/commands/sibling.ts
7537
+ var siblingCommand = new Command22("sibling").description(
7263
7538
  "Create and manage sibling apps that share a Biffo core project (ADR-0007)"
7264
7539
  );
7265
7540
  siblingCommand.addCommand(siblingCreateCommand);
7541
+ siblingCommand.addCommand(siblingCheckIdentityCommand);
7266
7542
 
7267
7543
  // src/commands/check.ts
7268
- import { Command as Command22 } from "commander";
7544
+ import { Command as Command23 } from "commander";
7269
7545
 
7270
7546
  // src/scripts/check-core-ownership.ts
7271
7547
  import { execa as execa5 } from "execa";
@@ -7291,8 +7567,8 @@ async function runOwnershipCheck(argv) {
7291
7567
  const { stdout } = await execa5("git", ["diff", "--cached", "--name-status"], { cwd: root });
7292
7568
  ({ changed: changedFiles, deleted: deletedFiles } = parseNameStatus(stdout));
7293
7569
  if (messageFile) {
7294
- const { readFileSync: readFileSync23, existsSync: existsSync30 } = await import("fs");
7295
- if (existsSync30(messageFile)) commitMessage = readFileSync23(messageFile, "utf8");
7570
+ const { readFileSync: readFileSync24, existsSync: existsSync31 } = await import("fs");
7571
+ if (existsSync31(messageFile)) commitMessage = readFileSync24(messageFile, "utf8");
7296
7572
  }
7297
7573
  } else {
7298
7574
  const base = process.env["GITHUB_BASE_REF"] ?? args[0];
@@ -7396,7 +7672,7 @@ ${BOLD}If the divergence is deliberate${OFF}
7396
7672
  import { execa as execa6 } from "execa";
7397
7673
 
7398
7674
  // src/lib/plugin-terraform-guard.ts
7399
- import { existsSync as existsSync29, readFileSync as readFileSync22, readdirSync as readdirSync12 } from "fs";
7675
+ import { existsSync as existsSync30, readFileSync as readFileSync23, readdirSync as readdirSync12 } from "fs";
7400
7676
  import { dirname as dirname9, join as join30, relative as relative6, sep as sep3 } from "path";
7401
7677
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".worktrees", "dist", ".venv", "__pycache__"]);
7402
7678
  var PLUGIN_MANIFEST_FILE2 = "biffo.plugin.json";
@@ -7424,7 +7700,7 @@ function findPluginManifests(root) {
7424
7700
  function readSubscriptions(absManifestPath) {
7425
7701
  let parsed;
7426
7702
  try {
7427
- parsed = JSON.parse(readFileSync22(absManifestPath, "utf8"));
7703
+ parsed = JSON.parse(readFileSync23(absManifestPath, "utf8"));
7428
7704
  } catch {
7429
7705
  return null;
7430
7706
  }
@@ -7439,14 +7715,14 @@ function readSubscriptions(absManifestPath) {
7439
7715
  }
7440
7716
  function checkPluginTerraform(root) {
7441
7717
  const violations = [];
7442
- const coreManifest = existsSync29(join30(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
7718
+ const coreManifest = existsSync30(join30(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
7443
7719
  for (const manifest of findPluginManifests(root)) {
7444
7720
  if (coreManifest && !isTemplateOwned(manifest, coreManifest)) continue;
7445
7721
  const absManifest = join30(root, manifest);
7446
7722
  const subscriptions = readSubscriptions(absManifest);
7447
7723
  if (subscriptions === null) continue;
7448
7724
  const pluginDir2 = dirname9(absManifest);
7449
- if (existsSync29(join30(pluginDir2, "terraform"))) continue;
7725
+ if (existsSync30(join30(pluginDir2, "terraform"))) continue;
7450
7726
  const relPluginDir = relative6(root, pluginDir2).split(sep3).join("/");
7451
7727
  violations.push({
7452
7728
  manifest,
@@ -7565,7 +7841,7 @@ async function runReleaseSubjectCheck(argv) {
7565
7841
  }
7566
7842
 
7567
7843
  // src/commands/check.ts
7568
- var checkCommand = new Command22("check").description(
7844
+ var checkCommand = new Command23("check").description(
7569
7845
  "Repo guards (ownership, release subject, plugin terraform) \u2014 run in CI and git hooks"
7570
7846
  );
7571
7847
  checkCommand.command("ownership").description("Refuse changes to template-owned paths in an instance (#370)").argument("[base]", "Base branch to diff against; defaults to $GITHUB_BASE_REF").option("--staged <messageFile>", "Check staged changes instead of a branch diff (commit hook)").allowExcessArguments(true).action(async () => {
@@ -7585,17 +7861,17 @@ function rawArgsAfter(subcommand) {
7585
7861
  // src/commands/teardown.ts
7586
7862
  import { execSync as execSync7 } from "child_process";
7587
7863
  import { GetCallerIdentityCommand as GetCallerIdentityCommand3, STSClient as STSClient3 } from "@aws-sdk/client-sts";
7588
- import chalk20 from "chalk";
7589
- import { Command as Command23 } from "commander";
7864
+ import chalk21 from "chalk";
7865
+ import { Command as Command24 } from "commander";
7590
7866
  import inquirer8 from "inquirer";
7591
- var teardownCommand = new Command23("teardown").description(
7867
+ var teardownCommand = new Command24("teardown").description(
7592
7868
  "Destroy all infrastructure then remove the repo, IAM role, and state bucket \u2014 single command"
7593
7869
  ).option("--project <name>", "Project name to tear down (reads session if omitted)").option("--skip-destroy", "Skip terraform destroy (only use if infrastructure is already gone)").option(
7594
7870
  "--confirm <name>",
7595
7871
  "Pre-confirm by supplying the project name (the scriptable form of the typed confirmation)"
7596
7872
  ).option("-y, --yes", "Skip the typed project-name confirmation entirely").action(
7597
7873
  async (options) => {
7598
- console.log(chalk20.bold("\n Biffo \u2014 Teardown\n"));
7874
+ console.log(chalk21.bold("\n Biffo \u2014 Teardown\n"));
7599
7875
  const githubToken = resolveGithubToken4();
7600
7876
  const sts = new STSClient3({});
7601
7877
  const { Account: accountId } = await sts.send(new GetCallerIdentityCommand3({}));
@@ -7617,7 +7893,7 @@ var teardownCommand = new Command23("teardown").description(
7617
7893
  adminEmail = session.config.admin?.email ?? "noop@example.com";
7618
7894
  adminUsername = session.config.admin?.username ?? "noop";
7619
7895
  domain = session.config.project.domain ?? "";
7620
- console.log(chalk20.yellow(" Loaded session for: ") + chalk20.bold(projectName));
7896
+ console.log(chalk21.yellow(" Loaded session for: ") + chalk21.bold(projectName));
7621
7897
  if (org && repo) console.log(` Repository: ${org}/${repo}`);
7622
7898
  console.log();
7623
7899
  } else if (savedConfig) {
@@ -7630,7 +7906,7 @@ var teardownCommand = new Command23("teardown").description(
7630
7906
  adminEmail = savedConfig.admin.email;
7631
7907
  adminUsername = savedConfig.admin.username;
7632
7908
  domain = resolveDnsConfig(savedConfig).domain;
7633
- console.log(chalk20.yellow(" Loaded config for: ") + chalk20.bold(projectName));
7909
+ console.log(chalk21.yellow(" Loaded config for: ") + chalk21.bold(projectName));
7634
7910
  console.log(` Repository: ${org}/${repo}`);
7635
7911
  console.log();
7636
7912
  } else {
@@ -7701,35 +7977,35 @@ var teardownCommand = new Command23("teardown").description(
7701
7977
  throw err;
7702
7978
  }
7703
7979
  }
7704
- console.log(chalk20.red.bold(" This will permanently delete:\n"));
7980
+ console.log(chalk21.red.bold(" This will permanently delete:\n"));
7705
7981
  if (deployedEnvs.length > 0 || hasGlobal) {
7706
- console.log(chalk20.red(" Infrastructure (via GitHub Actions terraform destroy):"));
7982
+ console.log(chalk21.red(" Infrastructure (via GitHub Actions terraform destroy):"));
7707
7983
  for (const env of deployedEnvs) {
7708
7984
  console.log(
7709
- ` ${chalk20.red("\u2717")} ${env} \u2014 VPC, RDS, Lambda, Cognito, CloudFront, EventBridge`
7985
+ ` ${chalk21.red("\u2717")} ${env} \u2014 VPC, RDS, Lambda, Cognito, CloudFront, EventBridge`
7710
7986
  );
7711
7987
  }
7712
7988
  if (hasGlobal) {
7713
- console.log(` ${chalk20.red("\u2717")} global \u2014 Route 53 hosted zone, ACM certificate`);
7989
+ console.log(` ${chalk21.red("\u2717")} global \u2014 Route 53 hosted zone, ACM certificate`);
7714
7990
  }
7715
7991
  console.log();
7716
7992
  }
7717
7993
  for (const line of formatSiblingPlan(siblings, options.skipDestroy === true)) {
7718
7994
  console.log(line);
7719
7995
  }
7720
- console.log(chalk20.red(" Biffo resources:"));
7721
- console.log(` ${chalk20.red("\u2717")} GitHub repository ${chalk20.bold(`${org}/${repo}`)}`);
7996
+ console.log(chalk21.red(" Biffo resources:"));
7997
+ console.log(` ${chalk21.red("\u2717")} GitHub repository ${chalk21.bold(`${org}/${repo}`)}`);
7722
7998
  console.log(
7723
- ` ${chalk20.red("\u2717")} IAM role ${chalk20.bold(`biffo-github-actions-${projectName}`)}`
7999
+ ` ${chalk21.red("\u2717")} IAM role ${chalk21.bold(`biffo-github-actions-${projectName}`)}`
7724
8000
  );
7725
8001
  console.log(
7726
- ` ${chalk20.red("\u2717")} S3 bucket ${chalk20.bold(stateBucket)} (all versions)`
8002
+ ` ${chalk21.red("\u2717")} S3 bucket ${chalk21.bold(stateBucket)} (all versions)`
7727
8003
  );
7728
- console.log(` ${chalk20.red("\u2717")} Local session file`);
8004
+ console.log(` ${chalk21.red("\u2717")} Local session file`);
7729
8005
  if (options.skipDestroy) {
7730
8006
  console.log();
7731
8007
  console.log(
7732
- chalk20.yellow(
8008
+ chalk21.yellow(
7733
8009
  " --skip-destroy: NO terraform destroy runs, for this project or any sibling.\n Everything above is deleted, but any infrastructure still standing is left\n standing \u2014 and, with the state buckets gone, orphaned."
7734
8010
  )
7735
8011
  );
@@ -7917,30 +8193,30 @@ async function assertSiblingsAreDestroyable(github, siblings) {
7917
8193
  }
7918
8194
  function formatSiblingPlan(siblings, skipDestroy) {
7919
8195
  if (siblings.length === 0) return [];
7920
- const lines = [chalk20.red(` Sibling apps (${siblings.length}) \u2014 ADR-0007:`)];
8196
+ const lines = [chalk21.red(` Sibling apps (${siblings.length}) \u2014 ADR-0007:`)];
7921
8197
  for (const s of siblings) {
7922
8198
  const envs = s.environments.join(", ");
7923
8199
  if (s.repoState === "gone") {
7924
8200
  lines.push(
7925
- ` ${chalk20.yellow("!")} ${chalk20.bold(`${s.org}/${s.repo}`)} \u2014 repo already deleted; its ${envs} infrastructure CANNOT be destroyed and will be left standing`
8201
+ ` ${chalk21.yellow("!")} ${chalk21.bold(`${s.org}/${s.repo}`)} \u2014 repo already deleted; its ${envs} infrastructure CANNOT be destroyed and will be left standing`
7926
8202
  );
7927
8203
  continue;
7928
8204
  }
7929
8205
  lines.push(
7930
- ` ${chalk20.red("\u2717")} GitHub repository ${chalk20.bold(`${s.org}/${s.repo}`)} ` + (s.pathPrefix === ROOT_SIBLING_NAME ? "(the application, routed at /)" : `(routed at /${s.pathPrefix})`)
8206
+ ` ${chalk21.red("\u2717")} GitHub repository ${chalk21.bold(`${s.org}/${s.repo}`)} ` + (s.pathPrefix === ROOT_SIBLING_NAME ? "(the application, routed at /)" : `(routed at /${s.pathPrefix})`)
7931
8207
  );
7932
8208
  lines.push(
7933
- ` ${chalk20.red("\u2717")} ${skipDestroy ? "infrastructure NOT destroyed (--skip-destroy)" : `${envs} infrastructure \u2014 S3 site bucket, Lambda, API Gateway`}`
8209
+ ` ${chalk21.red("\u2717")} ${skipDestroy ? "infrastructure NOT destroyed (--skip-destroy)" : `${envs} infrastructure \u2014 S3 site bucket, Lambda, API Gateway`}`
7934
8210
  );
7935
8211
  lines.push(
7936
- ` ${chalk20.red("\u2717")} IAM role ${chalk20.bold(`biffo-github-actions-${s.projectName}`)}`
8212
+ ` ${chalk21.red("\u2717")} IAM role ${chalk21.bold(`biffo-github-actions-${s.projectName}`)}`
7937
8213
  );
7938
8214
  lines.push(
7939
- ` ${chalk20.red("\u2717")} S3 bucket ${chalk20.bold(`${s.projectName}-terraform-state-${s.accountId}`)}`
8215
+ ` ${chalk21.red("\u2717")} S3 bucket ${chalk21.bold(`${s.projectName}-terraform-state-${s.accountId}`)}`
7940
8216
  );
7941
8217
  if (s.pendingRegistrationPr !== void 0) {
7942
8218
  lines.push(
7943
- chalk20.dim(` registration PR #${s.pendingRegistrationPr} is still open \u2014 never routed`)
8219
+ chalk21.dim(` registration PR #${s.pendingRegistrationPr} is still open \u2014 never routed`)
7944
8220
  );
7945
8221
  }
7946
8222
  }
@@ -7978,7 +8254,7 @@ async function confirmTeardown(projectName, options) {
7978
8254
  {
7979
8255
  type: "input",
7980
8256
  name: "confirm",
7981
- message: `Type ${chalk20.bold(projectName)} to confirm:`
8257
+ message: `Type ${chalk21.bold(projectName)} to confirm:`
7982
8258
  }
7983
8259
  ]);
7984
8260
  return confirm === projectName;
@@ -7996,7 +8272,7 @@ function resolveGithubToken4() {
7996
8272
  }
7997
8273
 
7998
8274
  // src/index.ts
7999
- var program = new Command24();
8275
+ var program = new Command25();
8000
8276
  function cliVersion() {
8001
8277
  try {
8002
8278
  return getLatestCoreVersion();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.79.0",
3
+ "version": "0.81.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",