@marcusrbrown/infra 0.8.0 → 0.9.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.
@@ -1,9 +1,10 @@
1
1
  /// <reference types="bun" />
2
2
 
3
3
  import type {SpinnerResult} from '@clack/prompts'
4
- import {afterEach, describe, expect, it, mock, spyOn} from 'bun:test'
4
+ import type {ProviderId} from './setup/providers'
5
+ import {log} from '@clack/prompts'
6
+ import {afterEach, beforeEach, describe, expect, it, mock, spyOn} from 'bun:test'
5
7
  import {goke} from 'goke'
6
-
7
8
  import {
8
9
  buildNonInteractivePlan,
9
10
  redactKey,
@@ -395,9 +396,9 @@ describe('destructive overwrite UX', () => {
395
396
  })
396
397
 
397
398
  // ── Smoke test runner tests moved to setup/smoke-test.test.ts ─────────────────
398
- // ── P1 regression tests ───────────────────────────────────────────────────────
399
+ // ── regression tests ────────────────────────────────────────────────────────────────────────────────────────────────────────────────
399
400
 
400
- describe('P1 #1 regression — dry-run early return before mutations', () => {
401
+ describe('dry-run early return before mutations', () => {
401
402
  const BASE_URL = 'https://cliproxy.fro.bot'
402
403
  const KEY = 'sk-test-key'
403
404
 
@@ -444,7 +445,7 @@ describe('P1 #1 regression — dry-run early return before mutations', () => {
444
445
  })
445
446
  })
446
447
 
447
- describe('P1 #2 regression — --force honored by non-interactive collision gate', () => {
448
+ describe('--force honored by non-interactive collision gate', () => {
448
449
  // The collision gate lives in runSetupCommand (not exported), so we test the
449
450
  // surrounding logic: buildNonInteractivePlan succeeds with --force, and the
450
451
  // collision gate behavior is verified via the error message shape.
@@ -516,7 +517,7 @@ describe('P1 #2 regression — --force honored by non-interactive collision gate
516
517
  })
517
518
  })
518
519
 
519
- describe('safe_auto #2 regression — /v1/models body Bearer token redaction', () => {
520
+ describe('/v1/models body Bearer token redaction', () => {
520
521
  const BASE_URL = 'https://cliproxy.fro.bot'
521
522
  const KEY = 'sk-test-key'
522
523
 
@@ -577,8 +578,6 @@ describe('safe_auto #2 regression — /v1/models body Bearer token redaction', (
577
578
  })
578
579
  })
579
580
 
580
- /* eslint-disable @typescript-eslint/no-explicit-any -- spyOn mock return values require `any` casts */
581
-
582
581
  // Fix 3 — dry-run isolation regression tests
583
582
  //
584
583
  // The action handler in registerCliproxySetup is not exported, so we test the
@@ -607,9 +606,9 @@ describe('cliproxy setup --dry-run is offline-safe (action handler contract)', (
607
606
 
608
607
  it('dry-run skips gh auth check — Bun.spawn not called during buildNonInteractivePlan', async () => {
609
608
  // Spy Bun.spawn to fail hard if called (simulates unauthenticated environment)
610
- spawnSpy = spyOn(Bun, 'spawn').mockImplementation((..._args: any[]) => {
609
+ spawnSpy = spyOn(Bun, 'spawn').mockImplementation(((_cmds: string[]) => {
611
610
  throw new Error('gh auth status called during dry-run — should be skipped')
612
- })
611
+ }) as unknown as typeof Bun.spawn)
613
612
 
614
613
  // Should complete without throwing (dry-run early return in buildNonInteractivePlan)
615
614
  const plan = await buildNonInteractivePlan({repo: 'owner/repo', harness: 'opencode', dryRun: true}, BASE_URL)
@@ -678,7 +677,6 @@ describe('cliproxy setup --dry-run is offline-safe (action handler contract)', (
678
677
  expect(fetchMock.mock.calls.length).toBeGreaterThan(0)
679
678
  })
680
679
  })
681
- /* eslint-enable @typescript-eslint/no-explicit-any */
682
680
 
683
681
  // ── runSetupCommand DI boundary tests ─────────────────────────────────
684
682
 
@@ -1373,14 +1371,7 @@ describe('runSetupCommand action handler', () => {
1373
1371
  ).resolves.toBeUndefined()
1374
1372
  })
1375
1373
 
1376
- // ── Interactive R8 ack-key-reuse prompt: redaction + cancel/continue ──────────
1377
- //
1378
- // Note: full interactive integration tests are limited by the F16 (issue #311) gap —
1379
- // buildInteractivePlan calls real @clack/prompts.text() for the key-name and harness
1380
- // prompts that DI doesn't cover yet. We test the redaction contract directly via
1381
- // the exported redactKey helper, then a unit test confirms the prompt template uses
1382
- // the redacted form. Interactive cancel/continue paths are exercised under F16 once
1383
- // RunSetupDeps covers all prompt sites.
1374
+ // ── Interactive key-reuse confirm prompt: redaction + cancel/continue ──────────
1384
1375
 
1385
1376
  it('redactKey: keys >= 12 chars use first-3 + *** + last-4 shape', () => {
1386
1377
  expect(redactKey('sk-PLAINTEXT-LONGKEY')).toBe('sk-***GKEY')
@@ -1400,8 +1391,8 @@ describe('runSetupCommand action handler', () => {
1400
1391
  expect(redacted.length).toBeLessThan(RAW.length)
1401
1392
  })
1402
1393
 
1403
- it('Interactive R8 prompt template uses redactKey output, never the raw key (source-level contract)', async () => {
1404
- // Read the setup.ts source and assert the R8 prompt-message template uses ${redactKey(options.key)}
1394
+ it('interactive key-reuse prompt template uses redactKey output, never the raw key (source-level contract)', async () => {
1395
+ // Read the setup.ts source and assert the key-reuse prompt-message template uses ${redactKey(options.key)}
1405
1396
  // and never `${options.key}` raw. This is a source-level guard so a future refactor that
1406
1397
  // accidentally drops the redaction call fails the test even if integration coverage lags.
1407
1398
  const source = await Bun.file(new URL('./setup.ts', import.meta.url).pathname).text()
@@ -1412,25 +1403,21 @@ describe('runSetupCommand action handler', () => {
1412
1403
  expect(promptContext).not.toMatch(/--key \$\{options\.key\}/)
1413
1404
  })
1414
1405
 
1415
- // The two interactive R8 integration tests below are skipped pending F16 (issue #311):
1416
- // RunSetupDeps must cover the buildInteractivePlan prompt sites before the interactive
1417
- // path can be exercised end-to-end with deps mocks alone.
1418
-
1419
- it.skip('Interactive R8: confirm prompt fires with redacted key (not raw token)', async () => {
1406
+ it('interactive key-reuse confirm shows a redacted key, never the raw token', async () => {
1420
1407
  const {ctx} = makeCtx()
1421
1408
  const PLAINTEXT_KEY = 'sk-PLAINTEXT-LONGKEY-SHOULD-NOT-LEAK'
1422
1409
  const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
1423
1410
  const originalFetch = globalThis.fetch
1424
1411
  globalThis.fetch = mock(async () => new Response(JSON.stringify(MODELS_FIXTURE))) as unknown as typeof fetch
1425
1412
 
1426
- let confirmMessage = ''
1413
+ const confirmMessages: string[] = []
1427
1414
  const captureConfirm = (opts: {message: string}): Promise<boolean | symbol> => {
1428
- confirmMessage = opts.message
1415
+ confirmMessages.push(opts.message)
1429
1416
  return Promise.resolve(true)
1430
1417
  }
1431
1418
 
1432
1419
  // Interactive mode resolves promptValue to the awaited prompt result. Our captureConfirm
1433
- // returns true, so the wizard proceeds past the R8 gate. We assert on the captured message.
1420
+ // returns true, so the wizard proceeds past the key-reuse gate. We assert on the captured message.
1434
1421
  const interactivePromptValue = async <T>(prompt: Promise<T | symbol>): Promise<T> => {
1435
1422
  const result = await prompt
1436
1423
  return result as T
@@ -1466,6 +1453,8 @@ describe('runSetupCommand action handler', () => {
1466
1453
  intro: () => {},
1467
1454
  note: () => {},
1468
1455
  outro: () => {},
1456
+ promptForProviders: (): Promise<ProviderId[]> => Promise.resolve(['openai']),
1457
+ promptForModel: (_providers: ProviderId[]): Promise<string> => Promise.resolve('openai/gpt-5.4-mini'),
1469
1458
  },
1470
1459
  smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1471
1460
  validation: {
@@ -1479,18 +1468,19 @@ describe('runSetupCommand action handler', () => {
1479
1468
  globalThis.fetch = originalFetch
1480
1469
  }
1481
1470
 
1482
- // The R8 confirm prompt must be the one captured (interactive flow has more than one confirm
1483
- // in some paths; we look for the one containing the verify-bearer language).
1484
- expect(confirmMessage).toContain('Verify it matches the bearer token')
1471
+ // The key-reuse confirm prompt must appear among the captured confirms (interactive flow
1472
+ // has more than one confirm in some paths; we find the one with the verify-bearer language).
1473
+ const keyReuseMessage = confirmMessages.find(m => m.includes('Verify it matches the bearer token'))
1474
+ expect(keyReuseMessage).toBeDefined()
1485
1475
  // The raw key must NEVER appear in the prompt text — security regression guard.
1486
- expect(confirmMessage).not.toContain(PLAINTEXT_KEY)
1476
+ expect(keyReuseMessage).not.toContain(PLAINTEXT_KEY)
1487
1477
  // Redacted form must be present (first 3 + last 4 chars per redactKey helper).
1488
- expect(confirmMessage).toContain('sk-')
1489
- expect(confirmMessage).toContain('***')
1490
- expect(confirmMessage).toContain('LEAK')
1478
+ expect(keyReuseMessage).toContain('sk-')
1479
+ expect(keyReuseMessage).toContain('***')
1480
+ expect(keyReuseMessage).toContain('LEAK')
1491
1481
  })
1492
1482
 
1493
- it.skip('Interactive R8: confirm returns false cancelAndExit invoked, applyGhValue never called', async () => {
1483
+ it('interactive key-reuse confirm returning false cancels before any GitHub write', async () => {
1494
1484
  const {ctx} = makeCtx()
1495
1485
  const MODELS_FIXTURE = {data: [{id: 'gpt-5.4-mini', owned_by: 'openai'}]}
1496
1486
  const originalFetch = globalThis.fetch
@@ -1499,6 +1489,17 @@ describe('runSetupCommand action handler', () => {
1499
1489
  let applyGhValueCalled = false
1500
1490
  let exitCode: number | undefined
1501
1491
 
1492
+ // The interactive flow shows a generic "Proceed?" confirm BEFORE the key-reuse
1493
+ // gate. Approve the generic prompt so the run actually reaches the key-reuse
1494
+ // confirm, then reject only that one — otherwise the test would cancel at the
1495
+ // first prompt and never exercise the gate it claims to cover.
1496
+ const confirmMessages: string[] = []
1497
+ const messageAwareConfirm = (opts: {message: string}): Promise<boolean | symbol> => {
1498
+ confirmMessages.push(opts.message)
1499
+ const isKeyReusePrompt = opts.message.includes('Verify it matches the bearer token')
1500
+ return Promise.resolve(!isKeyReusePrompt)
1501
+ }
1502
+
1502
1503
  const interactivePromptValue = async <T>(prompt: Promise<T | symbol>): Promise<T> => {
1503
1504
  const result = await prompt
1504
1505
  return result as T
@@ -1540,11 +1541,13 @@ describe('runSetupCommand action handler', () => {
1540
1541
  },
1541
1542
  prompts: {
1542
1543
  promptValue: interactivePromptValue,
1543
- // User rejects the R8 confirmation cancelAndExit fires.
1544
- confirm: () => Promise.resolve(false) as Promise<boolean | symbol>,
1544
+ // Approve the generic proceed confirm, reject only the key-reuse confirm.
1545
+ confirm: messageAwareConfirm,
1545
1546
  intro: () => {},
1546
1547
  note: () => {},
1547
1548
  outro: () => {},
1549
+ promptForProviders: (): Promise<ProviderId[]> => Promise.resolve(['openai']),
1550
+ promptForModel: (_providers: ProviderId[]): Promise<string> => Promise.resolve('openai/gpt-5.4-mini'),
1548
1551
  },
1549
1552
  smoke: {runSmokeTest: async () => ({kind: 'pass', message: 'ok', runUrl: 'https://example.com/run/1'})},
1550
1553
  validation: {
@@ -1555,7 +1558,7 @@ describe('runSetupCommand action handler', () => {
1555
1558
  },
1556
1559
  )
1557
1560
  // If we get here, cancelAndExit didn't fire. Fail the test.
1558
- throw new Error('expected cancelAndExit to fire on R8 reject')
1561
+ throw new Error('expected cancelAndExit to fire on key-reuse reject')
1559
1562
  } catch (error) {
1560
1563
  // cancelAndExit throws because we stubbed process.exit to throw.
1561
1564
  expect(error instanceof Error && error.message).toBe('process.exit-stubbed')
@@ -1564,6 +1567,9 @@ describe('runSetupCommand action handler', () => {
1564
1567
  globalThis.fetch = originalFetch
1565
1568
  }
1566
1569
 
1570
+ // Guard against a vacuous pass: the run must have actually reached the
1571
+ // key-reuse confirm, not cancelled at the earlier generic proceed prompt.
1572
+ expect(confirmMessages.some(m => m.includes('Verify it matches the bearer token'))).toBe(true)
1567
1573
  expect(exitCode).toBe(0)
1568
1574
  expect(applyGhValueCalled).toBe(false)
1569
1575
  })
@@ -1802,9 +1808,9 @@ describe('runSetupCommand action handler', () => {
1802
1808
  expect(deleteCalledWith).toBeDefined()
1803
1809
  })
1804
1810
 
1805
- // ── F5: --dry-run with no --repo/--harness ─────────────────────────────────
1811
+ // ── --dry-run with no --repo/--harness ─────────────────────────────────
1806
1812
 
1807
- it('F5: --dry-run with no --repo/--harness prints preview and does not throw', async () => {
1813
+ it('--dry-run with no --repo/--harness prints preview and does not throw', async () => {
1808
1814
  const {ctx, logs} = makeCtx()
1809
1815
  await runSetupCommand({dryRun: true}, {ctx})
1810
1816
  const output = logs.map(args => args.join(' ')).join('\n')
@@ -1812,7 +1818,7 @@ describe('runSetupCommand action handler', () => {
1812
1818
  expect(output).toContain('No mutations will be performed.')
1813
1819
  })
1814
1820
 
1815
- it('F5: --dry-run does not call assertGhInstalled even with no flags', async () => {
1821
+ it('--dry-run does not call assertGhInstalled even with no flags', async () => {
1816
1822
  const {ctx} = makeCtx()
1817
1823
  let ghCalled = false
1818
1824
  await runSetupCommand(
@@ -1836,9 +1842,9 @@ describe('runSetupCommand action handler', () => {
1836
1842
  expect(ghCalled).toBe(false)
1837
1843
  })
1838
1844
 
1839
- // ── F8: Rollback event-order assertions ────────────────────────────────────
1845
+ // ── Rollback event-order assertions ────────────────────────────────────
1840
1846
 
1841
- it('F8: applyGhValue fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
1847
+ it('applyGhValue fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
1842
1848
  const {ctx} = makeCtx()
1843
1849
  const events: string[] = []
1844
1850
 
@@ -1887,7 +1893,7 @@ describe('runSetupCommand action handler', () => {
1887
1893
  expect(events).toEqual(['create', 'apply-fail', 'delete'])
1888
1894
  })
1889
1895
 
1890
- it('F8: assertProxyKeyWorks fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
1896
+ it('assertProxyKeyWorks fails → deleteManagementApiKey called BEFORE error propagates (event order)', async () => {
1891
1897
  const {ctx} = makeCtx()
1892
1898
  const events: string[] = []
1893
1899
 
@@ -1938,9 +1944,9 @@ describe('runSetupCommand action handler', () => {
1938
1944
  expect(events).toEqual(['create', 'apply-success', 'verify-fail', 'delete'])
1939
1945
  })
1940
1946
 
1941
- // ── F9: --force pre-gate fires before verifyModelsAvailable ────────────────
1947
+ // ── --force pre-gate fires before verifyModelsAvailable ────────────────
1942
1948
 
1943
- it('F9: missing --force on provider change does not call fetch (verifyModelsAvailable skipped)', async () => {
1949
+ it('missing --force on provider change does not call fetch (verifyModelsAvailable skipped)', async () => {
1944
1950
  let fetchCalled = false
1945
1951
  const originalFetch = globalThis.fetch
1946
1952
  globalThis.fetch = mock(async () => {
@@ -1959,3 +1965,403 @@ describe('runSetupCommand action handler', () => {
1959
1965
  expect(fetchCalled).toBe(false)
1960
1966
  })
1961
1967
  })
1968
+
1969
+ // ── post-write readback verification ──────────────────────────────────────────
1970
+
1971
+ describe('post-write readback verification', () => {
1972
+ const BASE_URL = 'https://cliproxy.fro.bot'
1973
+ const KEY = 'sk-test-key'
1974
+
1975
+ // Capture log.warn calls from @clack/prompts
1976
+ let warnSpy: ReturnType<typeof spyOn>
1977
+ let warnMessages: string[]
1978
+
1979
+ // Standard DI deps for a successful non-interactive setup run.
1980
+ // listExistingGhNames is overridden per-test to control readback behavior.
1981
+ function makeDeps(
1982
+ listExistingGhNames: (repo: string, kind: 'secret' | 'variable') => Promise<string[]>,
1983
+ deleteManagementApiKey?: () => Promise<void>,
1984
+ ) {
1985
+ const {ctx} = makeCtx()
1986
+ return {
1987
+ ctx,
1988
+ deps: {
1989
+ interactive: false,
1990
+ baseUrl: BASE_URL,
1991
+ ctx,
1992
+ gh: {
1993
+ assertGhInstalled: async () => {},
1994
+ assertGhAuthenticated: async () => {},
1995
+ assertRepoAccess: async () => {},
1996
+ listExistingGhNames,
1997
+ createManagementApiKey: async () => {},
1998
+ deleteManagementApiKey: deleteManagementApiKey ?? (async () => {}),
1999
+ applyGhValue: async () => {},
2000
+ withGhRetry: async (_label, fn) => fn(makeSpinner()),
2001
+ },
2002
+ prompts: {
2003
+ promptValue: autoPromptValue,
2004
+ confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
2005
+ intro: () => {},
2006
+ note: () => {},
2007
+ outro: () => {},
2008
+ },
2009
+ smoke: {
2010
+ runSmokeTest: async () => ({kind: 'pass' as const, message: 'ok', runUrl: 'https://example.com/run/1'}),
2011
+ },
2012
+ validation: {
2013
+ assertProxyReachable: async () => {},
2014
+ assertProxyKeyWorks: async () => {},
2015
+ verifyModelsAvailable: async () => {},
2016
+ },
2017
+ } satisfies Parameters<typeof runSetupCommand>[1],
2018
+ }
2019
+ }
2020
+
2021
+ // Standard options for a non-interactive anthropic-only setup (no --force needed)
2022
+ const baseOptions = {
2023
+ key: KEY,
2024
+ repo: 'owner/repo',
2025
+ harness: 'opencode' as const,
2026
+ }
2027
+
2028
+ beforeEach(() => {
2029
+ warnMessages = []
2030
+ warnSpy = spyOn(log, 'warn').mockImplementation((msg: string) => {
2031
+ warnMessages.push(msg)
2032
+ })
2033
+ })
2034
+
2035
+ afterEach(() => {
2036
+ warnSpy.mockRestore()
2037
+ })
2038
+
2039
+ it('happy path: readback returns all written names → no new warning emitted', async () => {
2040
+ // Pre-write list is empty; post-write readback returns all written names
2041
+ // The opencode harness writes: OPENCODE_AUTH_JSON, OPENCODE_CONFIG, OMO_PROVIDERS (secrets)
2042
+ // and FRO_BOT_MODEL (variable)
2043
+ let callCount = 0
2044
+ const {deps} = makeDeps(async (_repo, kind) => {
2045
+ callCount++
2046
+ if (callCount <= 2) {
2047
+ // Pre-write calls: return empty (fresh repo)
2048
+ return []
2049
+ }
2050
+ // Post-write readback: return all written names
2051
+ if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG', 'OMO_PROVIDERS']
2052
+ return ['FRO_BOT_MODEL']
2053
+ })
2054
+
2055
+ await runSetupCommand(baseOptions, deps)
2056
+
2057
+ // No verified-mismatch or cannot-verify warning should have been emitted
2058
+ const readbackWarnings = warnMessages.filter(
2059
+ m => m.includes('not visible') || m.includes('could not verify') || m.includes('may have been bypassed'),
2060
+ )
2061
+ expect(readbackWarnings).toHaveLength(0)
2062
+ })
2063
+
2064
+ it('verified mismatch (secret): readback succeeds but written secret absent → loud warning naming absent secret', async () => {
2065
+ // Pre-write: empty. Post-write secret readback: missing OPENCODE_AUTH_JSON
2066
+ let callCount = 0
2067
+ const {deps} = makeDeps(async (_repo, kind) => {
2068
+ callCount++
2069
+ if (callCount <= 2) return []
2070
+ // Post-write: secret readback missing OPENCODE_AUTH_JSON
2071
+ if (kind === 'secret') return ['OPENCODE_CONFIG', 'OMO_PROVIDERS']
2072
+ return ['FRO_BOT_MODEL']
2073
+ })
2074
+
2075
+ await runSetupCommand(baseOptions, deps)
2076
+
2077
+ const mismatchWarnings = warnMessages.filter(m => m.includes('may have been bypassed'))
2078
+ expect(mismatchWarnings.length).toBeGreaterThan(0)
2079
+ // Must name the absent secret
2080
+ expect(mismatchWarnings.some(m => m.includes('OPENCODE_AUTH_JSON'))).toBe(true)
2081
+ // Must direct operator to manual verification
2082
+ expect(mismatchWarnings.some(m => m.includes('gh secret list'))).toBe(true)
2083
+ })
2084
+
2085
+ it('verified mismatch (variable): secret readback complete but variable absent → warning lists absent variable', async () => {
2086
+ // Pre-write: empty. Post-write: all secrets present, but FRO_BOT_MODEL missing from variables
2087
+ let callCount = 0
2088
+ const {deps} = makeDeps(async (_repo, kind) => {
2089
+ callCount++
2090
+ if (callCount <= 2) return []
2091
+ if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG', 'OMO_PROVIDERS']
2092
+ // Variable readback missing FRO_BOT_MODEL
2093
+ return []
2094
+ })
2095
+
2096
+ await runSetupCommand(baseOptions, deps)
2097
+
2098
+ const mismatchWarnings = warnMessages.filter(m => m.includes('may have been bypassed'))
2099
+ expect(mismatchWarnings.length).toBeGreaterThan(0)
2100
+ expect(mismatchWarnings.some(m => m.includes('FRO_BOT_MODEL'))).toBe(true)
2101
+ expect(mismatchWarnings.some(m => m.includes('gh variable list'))).toBe(true)
2102
+ })
2103
+
2104
+ it('partial visibility: readback shows some but not all written names → warning lists exactly the absent names', async () => {
2105
+ // Post-write: OPENCODE_CONFIG and OMO_PROVIDERS present, OPENCODE_AUTH_JSON absent
2106
+ let callCount = 0
2107
+ const {deps} = makeDeps(async (_repo, kind) => {
2108
+ callCount++
2109
+ if (callCount <= 2) return []
2110
+ if (kind === 'secret') return ['OPENCODE_CONFIG', 'OMO_PROVIDERS'] // OPENCODE_AUTH_JSON absent
2111
+ return ['FRO_BOT_MODEL']
2112
+ })
2113
+
2114
+ await runSetupCommand(baseOptions, deps)
2115
+
2116
+ const mismatchWarnings = warnMessages.filter(m => m.includes('may have been bypassed'))
2117
+ expect(mismatchWarnings.length).toBeGreaterThan(0)
2118
+ // Must name OPENCODE_AUTH_JSON (absent)
2119
+ expect(mismatchWarnings.some(m => m.includes('OPENCODE_AUTH_JSON'))).toBe(true)
2120
+ // Must NOT name OPENCODE_CONFIG or OMO_PROVIDERS (they ARE present)
2121
+ expect(mismatchWarnings.some(m => m.includes('OPENCODE_CONFIG'))).toBe(false)
2122
+ expect(mismatchWarnings.some(m => m.includes('OMO_PROVIDERS'))).toBe(false)
2123
+ })
2124
+
2125
+ it('cannot verify: listExistingGhNames throws on post-write call → softer warning, command does NOT throw, rollback NOT fired', async () => {
2126
+ let deleteCalledWith: string | undefined
2127
+ let callCount = 0
2128
+
2129
+ const {deps} = makeDeps(
2130
+ async (_repo, _kind) => {
2131
+ callCount++
2132
+ if (callCount <= 2) return [] // Pre-write calls succeed
2133
+ // Post-write readback throws
2134
+ throw new Error('gh: command failed')
2135
+ },
2136
+ async () => {
2137
+ deleteCalledWith = 'called'
2138
+ },
2139
+ )
2140
+
2141
+ // Command must NOT throw
2142
+ await expect(runSetupCommand(baseOptions, deps)).resolves.toBeUndefined()
2143
+
2144
+ // Must emit the cannot-verify warning (softer wording)
2145
+ const cannotVerifyWarnings = warnMessages.filter(m => m.includes('could not verify'))
2146
+ expect(cannotVerifyWarnings.length).toBeGreaterThan(0)
2147
+
2148
+ // Must NOT emit the verified-mismatch warning
2149
+ const mismatchWarnings = warnMessages.filter(m => m.includes('may have been bypassed'))
2150
+ expect(mismatchWarnings).toHaveLength(0)
2151
+
2152
+ // Rollback must NOT have fired (key was not created by this run since --key was supplied)
2153
+ expect(deleteCalledWith).toBeUndefined()
2154
+ })
2155
+
2156
+ it('createKey:true path — post-write readback throws → command resolves, key created, rollback suppressed', async () => {
2157
+ // This test drives the createKey:true path (no --key supplied, wizard mints a key).
2158
+ // The post-write readback throws after the key is created and secrets are written.
2159
+ // Asserts: (1) createManagementApiKey WAS called, (2) command resolves, (3) deleteManagementApiKey NOT called.
2160
+ const {ctx} = makeCtx()
2161
+
2162
+ let createCalled = false
2163
+ let deleteCalled = false
2164
+ let listCallCount = 0
2165
+
2166
+ await runSetupCommand(
2167
+ {
2168
+ // No --key → createKey=true
2169
+ repo: 'owner/repo',
2170
+ harness: 'claude-code',
2171
+ force: true,
2172
+ },
2173
+ {
2174
+ interactive: true,
2175
+ baseUrl: BASE_URL,
2176
+ ctx,
2177
+ resolveManagementKey: () => 'mgmt-test-key',
2178
+ gh: {
2179
+ assertGhInstalled: async () => {},
2180
+ assertGhAuthenticated: async () => {},
2181
+ assertRepoAccess: async () => {},
2182
+ listExistingGhNames: async (_repo, _kind) => {
2183
+ listCallCount++
2184
+ if (listCallCount <= 2) return [] // Pre-write calls succeed (empty repo)
2185
+ // Post-write readback throws
2186
+ throw new Error('gh: post-write readback failed')
2187
+ },
2188
+ createManagementApiKey: async () => {
2189
+ createCalled = true
2190
+ },
2191
+ deleteManagementApiKey: async () => {
2192
+ deleteCalled = true
2193
+ },
2194
+ applyGhValue: async () => {},
2195
+ withGhRetry: async (_label, fn) => fn(makeSpinner()),
2196
+ },
2197
+ prompts: {
2198
+ promptValue: autoPromptValue,
2199
+ confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
2200
+ intro: () => {},
2201
+ note: () => {},
2202
+ outro: () => {},
2203
+ },
2204
+ smoke: {
2205
+ runSmokeTest: async () => ({kind: 'pass' as const, message: 'ok', runUrl: 'https://example.com/run/1'}),
2206
+ },
2207
+ validation: {
2208
+ assertProxyReachable: async () => {},
2209
+ assertProxyKeyWorks: async () => {},
2210
+ verifyModelsAvailable: async () => {},
2211
+ },
2212
+ },
2213
+ )
2214
+
2215
+ // Key was created this run
2216
+ expect(createCalled).toBe(true)
2217
+ // Command resolved (did not throw)
2218
+ // (implicit — if it threw, the test would fail above)
2219
+ // Rollback must NOT have fired — post-write readback failure must not trigger key deletion
2220
+ expect(deleteCalled).toBe(false)
2221
+ })
2222
+
2223
+ it('whole-block guard: throw during diff/warning path → command does NOT throw, rollback NOT fired', async () => {
2224
+ // Simulate a throw that occurs after the gh calls succeed but during processing.
2225
+ // We do this by making the post-write secret readback return a value that causes
2226
+ // an error in the diff computation — specifically, we inject a non-iterable value
2227
+ // by making listExistingGhNames return a Proxy that throws on iteration.
2228
+ let deleteCalledWith: string | undefined
2229
+ let callCount = 0
2230
+
2231
+ const {deps} = makeDeps(
2232
+ async (_repo, _kind) => {
2233
+ callCount++
2234
+ if (callCount <= 2) return []
2235
+ // Return a value that will cause an error during set-difference computation:
2236
+ // a Proxy that throws when iterated
2237
+ const throwingArray = new Proxy([] as string[], {
2238
+ get(target, prop) {
2239
+ if (prop === 'includes' || prop === Symbol.iterator || prop === 'forEach') {
2240
+ throw new Error('injected-diff-error')
2241
+ }
2242
+ return Reflect.get(target, prop)
2243
+ },
2244
+ })
2245
+ return throwingArray
2246
+ },
2247
+ async () => {
2248
+ deleteCalledWith = 'called'
2249
+ },
2250
+ )
2251
+
2252
+ // Command must NOT throw
2253
+ await expect(runSetupCommand(baseOptions, deps)).resolves.toBeUndefined()
2254
+
2255
+ // Rollback must NOT have fired
2256
+ expect(deleteCalledWith).toBeUndefined()
2257
+ })
2258
+
2259
+ it('existing secret + ack-key-reuse: readback shows all names → no new warning', async () => {
2260
+ // Pre-write: OPENCODE_AUTH_JSON already exists (triggers ack-key-reuse path)
2261
+ // Post-write readback: all names present
2262
+ let callCount = 0
2263
+ const {deps} = makeDeps(async (_repo, kind) => {
2264
+ callCount++
2265
+ if (callCount <= 2) {
2266
+ // Pre-write: OPENCODE_AUTH_JSON exists
2267
+ if (kind === 'secret') return ['OPENCODE_AUTH_JSON']
2268
+ return []
2269
+ }
2270
+ // Post-write readback: all names present
2271
+ if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG', 'OMO_PROVIDERS']
2272
+ return ['FRO_BOT_MODEL']
2273
+ })
2274
+
2275
+ await runSetupCommand({...baseOptions, ackKeyReuse: true, force: true}, deps)
2276
+
2277
+ const readbackWarnings = warnMessages.filter(
2278
+ m => m.includes('not visible') || m.includes('could not verify') || m.includes('may have been bypassed'),
2279
+ )
2280
+ expect(readbackWarnings).toHaveLength(0)
2281
+ })
2282
+ })
2283
+
2284
+ // ── concurrency caveat on the non-interactive overwrite warning ─────────────────
2285
+
2286
+ describe('non-interactive overwrite warning concurrency caveat', () => {
2287
+ const BASE_URL = 'https://cliproxy.fro.bot'
2288
+ const KEY = 'sk-test-key'
2289
+
2290
+ let warnSpy: ReturnType<typeof spyOn>
2291
+ let warnMessages: string[]
2292
+
2293
+ function makeDeps(listExistingGhNames: (repo: string, kind: 'secret' | 'variable') => Promise<string[]>) {
2294
+ const {ctx} = makeCtx()
2295
+ return {
2296
+ interactive: false,
2297
+ baseUrl: BASE_URL,
2298
+ ctx,
2299
+ gh: {
2300
+ assertGhInstalled: async () => {},
2301
+ assertGhAuthenticated: async () => {},
2302
+ assertRepoAccess: async () => {},
2303
+ listExistingGhNames,
2304
+ createManagementApiKey: async () => {},
2305
+ deleteManagementApiKey: async () => {},
2306
+ applyGhValue: async () => {},
2307
+ withGhRetry: async (_label, fn) => fn(makeSpinner()),
2308
+ },
2309
+ prompts: {
2310
+ promptValue: autoPromptValue,
2311
+ confirm: () => Promise.resolve(true) as Promise<boolean | symbol>,
2312
+ intro: () => {},
2313
+ note: () => {},
2314
+ outro: () => {},
2315
+ },
2316
+ smoke: {
2317
+ runSmokeTest: async () => ({kind: 'pass' as const, message: 'ok', runUrl: 'https://example.com/run/1'}),
2318
+ },
2319
+ validation: {
2320
+ assertProxyReachable: async () => {},
2321
+ assertProxyKeyWorks: async () => {},
2322
+ verifyModelsAvailable: async () => {},
2323
+ },
2324
+ } satisfies Parameters<typeof runSetupCommand>[1]
2325
+ }
2326
+
2327
+ beforeEach(() => {
2328
+ warnMessages = []
2329
+ warnSpy = spyOn(log, 'warn').mockImplementation((msg: string) => {
2330
+ warnMessages.push(msg)
2331
+ })
2332
+ })
2333
+
2334
+ afterEach(() => {
2335
+ warnSpy.mockRestore()
2336
+ })
2337
+
2338
+ it('--force overwrite with a collision present → warning carries the last-write-wins concurrency caveat', async () => {
2339
+ // OPENCODE_AUTH_JSON already exists → collision on the opencode secret set → overwrite warning fires.
2340
+ const deps = makeDeps(async (_repo, kind) => {
2341
+ if (kind === 'secret') return ['OPENCODE_AUTH_JSON', 'OPENCODE_CONFIG', 'OMO_PROVIDERS']
2342
+ return ['FRO_BOT_MODEL']
2343
+ })
2344
+
2345
+ await runSetupCommand({key: KEY, repo: 'owner/repo', harness: 'opencode', ackKeyReuse: true, force: true}, deps)
2346
+
2347
+ const overwriteWarnings = warnMessages.filter(m => m.includes('Overwriting existing GitHub values'))
2348
+ expect(overwriteWarnings.length).toBeGreaterThan(0)
2349
+ expect(overwriteWarnings.some(m => m.includes('last-write-wins'))).toBe(true)
2350
+ expect(overwriteWarnings.some(m => m.includes('two places at once'))).toBe(true)
2351
+ })
2352
+
2353
+ it('--force with no collision → no overwrite warning, so no concurrency caveat (fresh-run race has no signal)', async () => {
2354
+ // Fresh repo: empty pre-write list → no collision → overwrite warning never fires.
2355
+ // This documents that the concurrency caveat does NOT cover the fresh-run race.
2356
+ const deps = makeDeps(async (_repo, kind) => {
2357
+ // Pre-write empty; post-write readback returns all written names (no readback warning either).
2358
+ if (kind === 'secret') return []
2359
+ return []
2360
+ })
2361
+
2362
+ await runSetupCommand({key: KEY, repo: 'owner/repo', harness: 'opencode', force: true}, deps)
2363
+
2364
+ const concurrencyWarnings = warnMessages.filter(m => m.includes('last-write-wins'))
2365
+ expect(concurrencyWarnings).toHaveLength(0)
2366
+ })
2367
+ })