@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.
- package/package.json +1 -1
- package/src/__snapshots__/cli.test.ts.snap +19 -1
- package/src/cli.ts +2 -0
- package/src/commands/cliproxy/setup/providers.test.ts +50 -33
- package/src/commands/cliproxy/setup/smoke-test.test.ts +178 -0
- package/src/commands/cliproxy/setup/smoke-test.ts +29 -11
- package/src/commands/cliproxy/setup.test.ts +454 -48
- package/src/commands/cliproxy/setup.ts +128 -4
- package/src/commands/mcp.test.ts +5 -1
- package/src/commands/mcp.ts +3 -0
- package/src/commands/status.test.ts +36 -0
- package/src/commands/status.ts +10 -3
- package/src/commands/umami/deploy.test.ts +202 -0
- package/src/commands/umami/deploy.ts +132 -0
- package/src/commands/umami/host.test.ts +62 -0
- package/src/commands/umami/host.ts +31 -0
- package/src/commands/umami/index.ts +13 -0
- package/src/commands/umami/logs.test.ts +154 -0
- package/src/commands/umami/logs.ts +161 -0
- package/src/commands/umami/status.test.ts +387 -0
- package/src/commands/umami/status.ts +267 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/// <reference types="bun" />
|
|
2
2
|
|
|
3
3
|
import type {SpinnerResult} from '@clack/prompts'
|
|
4
|
-
import {
|
|
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
|
-
// ──
|
|
399
|
+
// ── regression tests ────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
399
400
|
|
|
400
|
-
describe('
|
|
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('
|
|
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('
|
|
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((
|
|
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
|
|
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('
|
|
1404
|
-
// Read the setup.ts source and assert the
|
|
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
|
-
|
|
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
|
-
|
|
1413
|
+
const confirmMessages: string[] = []
|
|
1427
1414
|
const captureConfirm = (opts: {message: string}): Promise<boolean | symbol> => {
|
|
1428
|
-
|
|
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
|
|
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
|
|
1483
|
-
// in some paths; we
|
|
1484
|
-
|
|
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(
|
|
1476
|
+
expect(keyReuseMessage).not.toContain(PLAINTEXT_KEY)
|
|
1487
1477
|
// Redacted form must be present (first 3 + last 4 chars per redactKey helper).
|
|
1488
|
-
expect(
|
|
1489
|
-
expect(
|
|
1490
|
-
expect(
|
|
1478
|
+
expect(keyReuseMessage).toContain('sk-')
|
|
1479
|
+
expect(keyReuseMessage).toContain('***')
|
|
1480
|
+
expect(keyReuseMessage).toContain('LEAK')
|
|
1491
1481
|
})
|
|
1492
1482
|
|
|
1493
|
-
it
|
|
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
|
-
//
|
|
1544
|
-
confirm:
|
|
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
|
|
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
|
-
// ──
|
|
1811
|
+
// ── --dry-run with no --repo/--harness ─────────────────────────────────
|
|
1806
1812
|
|
|
1807
|
-
it('
|
|
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('
|
|
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
|
-
// ──
|
|
1845
|
+
// ── Rollback event-order assertions ────────────────────────────────────
|
|
1840
1846
|
|
|
1841
|
-
it('
|
|
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('
|
|
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
|
-
// ──
|
|
1947
|
+
// ── --force pre-gate fires before verifyModelsAvailable ────────────────
|
|
1942
1948
|
|
|
1943
|
-
it('
|
|
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
|
+
})
|