diff --git a/packages/cli/src/features/config/config.ts b/packages/cli/src/features/config/config.ts index 622d1fd..5a0ce97 100644 --- a/packages/cli/src/features/config/config.ts +++ b/packages/cli/src/features/config/config.ts @@ -1,59 +1,8 @@ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { ensureDir, getConfigDir } from '@spotify-confidence/core'; - -type ConfigKey = 'project' | 'environment' | 'output' | 'profile' | 'ide'; - -type Config = Partial>; - -const VALID_KEYS: readonly ConfigKey[] = ['project', 'environment', 'output', 'profile', 'ide']; - -function configPath(): string { - return join(getConfigDir(), 'config.json'); -} - -export function readConfig(): Config { - const path = configPath(); - if (!existsSync(path)) return {}; - try { - return JSON.parse(readFileSync(path, 'utf-8')) as Config; - } catch { - return {}; - } -} - -function writeConfig(config: Config): void { - const path = configPath(); - ensureDir(join(path, '..')); - writeFileSync(path, JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 }); -} - -export function getConfigValue(key: string): string | undefined { - validateKey(key); - return readConfig()[key as ConfigKey]; -} - -export function setConfigValue(key: string, value: string): void { - validateKey(key); - const config = readConfig(); - config[key as ConfigKey] = value; - writeConfig(config); -} - -export function resetConfig(): void { - writeConfig({}); -} - -export function isValidKey(key: string): key is ConfigKey { - return VALID_KEYS.includes(key as ConfigKey); -} - -export function validKeys(): readonly string[] { - return VALID_KEYS; -} - -function validateKey(key: string): asserts key is ConfigKey { - if (!isValidKey(key)) { - throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(', ')}`); - } -} +export { + readConfig, + getConfigValue, + setConfigValue, + resetConfig, + isValidKey, + validKeys, +} from '@spotify-confidence/core'; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts new file mode 100644 index 0000000..1eb0cf8 --- /dev/null +++ b/packages/core/src/config/config.ts @@ -0,0 +1,60 @@ +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { ensureDir } from '../auth/credentials/store.js'; +import { getConfigDir } from '../auth/credentials/paths.js'; + +type ConfigKey = 'project' | 'environment' | 'output' | 'profile' | 'ide'; + +type Config = Partial>; + +const VALID_KEYS: readonly ConfigKey[] = ['project', 'environment', 'output', 'profile', 'ide']; + +function configPath(): string { + return join(getConfigDir(), 'config.json'); +} + +export function readConfig(): Config { + const path = configPath(); + if (!existsSync(path)) return {}; + try { + return JSON.parse(readFileSync(path, 'utf-8')) as Config; + } catch { + return {}; + } +} + +function writeConfig(config: Config): void { + const path = configPath(); + ensureDir(join(path, '..')); + writeFileSync(path, JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 }); +} + +export function getConfigValue(key: string): string | undefined { + validateKey(key); + return readConfig()[key as ConfigKey]; +} + +export function setConfigValue(key: string, value: string): void { + validateKey(key); + const config = readConfig(); + config[key as ConfigKey] = value; + writeConfig(config); +} + +export function resetConfig(): void { + writeConfig({}); +} + +export function isValidKey(key: string): key is ConfigKey { + return VALID_KEYS.includes(key as ConfigKey); +} + +export function validKeys(): readonly string[] { + return VALID_KEYS; +} + +function validateKey(key: string): asserts key is ConfigKey { + if (!isValidKey(key)) { + throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(', ')}`); + } +} diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts new file mode 100644 index 0000000..8717de7 --- /dev/null +++ b/packages/core/src/config/index.ts @@ -0,0 +1,8 @@ +export { + readConfig, + getConfigValue, + setConfigValue, + resetConfig, + isValidKey, + validKeys, +} from './config.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2cc9d54..de5125a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,3 +9,4 @@ export * from './constants.js'; export * from './frameworks/index.js'; export * from './integrations/index.js'; export * from './providers/index.js'; +export * from './config/index.js'; diff --git a/packages/core/src/session/session.ts b/packages/core/src/session/session.ts index 27ea6ed..cd2acbf 100644 --- a/packages/core/src/session/session.ts +++ b/packages/core/src/session/session.ts @@ -154,6 +154,7 @@ export function createSession(opts?: { debug?: boolean; dir?: string; goals?: OnboardingGoal[]; + ide?: IdeId | null; }): WizardSession { return { sessionId: randomUUID(), @@ -167,7 +168,7 @@ export function createSession(opts?: { projectDir: opts?.dir ?? process.cwd(), systemChecks: {}, authState: { status: 'idle' }, - ide: null, + ide: opts?.ide ?? null, pluginTargets: [], pluginInstallMethod: null, connectedMcps: [], diff --git a/packages/quickstart/__tests__/e2e/saved-ide.e2e.ts b/packages/quickstart/__tests__/e2e/saved-ide.e2e.ts new file mode 100644 index 0000000..cc17f0e --- /dev/null +++ b/packages/quickstart/__tests__/e2e/saved-ide.e2e.ts @@ -0,0 +1,30 @@ +import { createSession, navigatePastWelcome, buildTestJwt } from './testing-framework/index.js'; + +describe('when IDE is saved in config from a previous run', () => { + it('auto-selects the saved IDE and skips the selection step', async () => { + using session = createSession({ + token: buildTestJwt(), + config: { ide: 'cursor' }, + }); + + await navigatePastWelcome(session); + + // Auth — use existing account (token was pre-seeded) + await session.waitForText('Use existing account'); + await session.press('Enter'); + await session.waitForText('Authenticated'); + + // SelectGoal + await session.waitForText("Select the features you'd like to set up"); + session.checkpoint(); + await session.press('Space'); + await session.press('Enter'); + + // InstallPlugins — should auto-select cursor without showing the IDE picker + await session.waitForText('Plugin set up successfully'); + session.checkpoint(); + + // ConnectTools — confirms we advanced past InstallPlugins + await session.waitForText('Teach your AI Confidence'); + }); +}); diff --git a/packages/quickstart/__tests__/e2e/testing-framework/session-factory.ts b/packages/quickstart/__tests__/e2e/testing-framework/session-factory.ts index a885bea..a6abb04 100644 --- a/packages/quickstart/__tests__/e2e/testing-framework/session-factory.ts +++ b/packages/quickstart/__tests__/e2e/testing-framework/session-factory.ts @@ -24,6 +24,8 @@ import { createProjectDir, type ProjectType } from '@spotify-confidence/testing/ * @param options.refreshToken - Refresh token written alongside the auth * token. Pass `null` to simulate a missing refresh token. * @defaultValue `'e2e-refresh-token'` + * @param options.config - Pre-seed Confidence config values (e.g. `{ ide: 'cursor' }`). + * Written to `config.json` in the session's config directory. * @param options.systemPath - Override `PATH` to control which system * binaries the wizard's system check can find. * @returns A disposable {@link TerminalSession} ready for interaction. @@ -43,6 +45,7 @@ export function createSession({ env = {}, token, refreshToken = 'e2e-refresh-token', + config, systemPath, }: { project?: ProjectType; @@ -50,6 +53,7 @@ export function createSession({ env?: Record; token?: string; refreshToken?: string | null; + config?: Record; systemPath?: string; } = {}): TerminalSession { const mockBinDir = process.env.E2E_MOCK_BIN_DIR!; @@ -60,15 +64,9 @@ export function createSession({ ...env, }; - if (token) { + if (token || config) { const configDir = mkdtempSync(join(tmpdir(), 'e2e-config-')); - - const credentials: Record = { accessToken: token }; - if (refreshToken) { - credentials.refreshToken = refreshToken; - } - - writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(credentials), 'utf-8'); + seedConfigDir(configDir, { token, refreshToken, config }); sessionEnv.CONFIDENCE_CONFIG_DIR = configDir; } @@ -81,3 +79,22 @@ export function createSession({ session.addTempDir(projectDir); return session; } + +function seedConfigDir( + dir: string, + opts: { + token?: string; + refreshToken?: string | null; + config?: Record; + }, +): void { + if (opts.token) { + const credentials: Record = { accessToken: opts.token }; + if (opts.refreshToken) credentials.refreshToken = opts.refreshToken; + writeFileSync(join(dir, 'credentials.json'), JSON.stringify(credentials), 'utf-8'); + } + + if (opts.config) { + writeFileSync(join(dir, 'config.json'), JSON.stringify(opts.config), 'utf-8'); + } +} diff --git a/packages/quickstart/__tests__/ui/screens/DoneScreen.test.tsx b/packages/quickstart/__tests__/ui/screens/DoneScreen.test.tsx index 752c374..6680924 100644 --- a/packages/quickstart/__tests__/ui/screens/DoneScreen.test.tsx +++ b/packages/quickstart/__tests__/ui/screens/DoneScreen.test.tsx @@ -6,6 +6,7 @@ import { store } from '@ui/store.js'; vi.mock('@spotify-confidence/core', async (importOriginal) => ({ ...(await importOriginal()), launchChatSession: vi.fn(), + getConfigValue: vi.fn().mockReturnValue(undefined), })); describe('DoneScreen', () => { diff --git a/packages/quickstart/__tests__/ui/screens/InstallPluginsScreen.test.tsx b/packages/quickstart/__tests__/ui/screens/InstallPluginsScreen.test.tsx index 4c51083..89f5f2f 100644 --- a/packages/quickstart/__tests__/ui/screens/InstallPluginsScreen.test.tsx +++ b/packages/quickstart/__tests__/ui/screens/InstallPluginsScreen.test.tsx @@ -7,6 +7,7 @@ import { ARROW_DOWN, waitFor, } from '../testing-framework/index.js'; +import { createConfigDir } from '@spotify-confidence/testing/scaffold'; import { InstallPluginsScreen } from '@ui/screens/install-plugins/index.js'; import { ScreenId } from '@spotify-confidence/core'; @@ -20,6 +21,7 @@ vi.mock('@spotify-confidence/core', async (importOriginal) => ({ describe('InstallPluginsScreen', () => { it('renders title', async () => { + using _config = createConfigDir(); using sut = renderScreen(, { screen: ScreenId.InstallPlugins }); await waitFor(() => { expect(sut.lastFrame()).toContain('Select agent to set up'); @@ -27,6 +29,7 @@ describe('InstallPluginsScreen', () => { }); it('shows IDE selection when no plugins detected', async () => { + using _config = createConfigDir(); using sut = renderScreen(, { screen: ScreenId.InstallPlugins }); await waitFor(() => { expect(sut.lastFrame()).toContain('Claude Code'); @@ -36,6 +39,7 @@ describe('InstallPluginsScreen', () => { }); it('shows file system access warning during IDE selection', async () => { + using _config = createConfigDir(); using sut = renderScreen(, { screen: ScreenId.InstallPlugins }); await waitFor(() => { expect(sut.lastFrame()).toContain('full file system access'); @@ -43,6 +47,7 @@ describe('InstallPluginsScreen', () => { }); it('installs plugin and shows success', async () => { + using _config = createConfigDir(); using project = createProjectDir(); using sut = renderScreen(, { screen: ScreenId.InstallPlugins, @@ -61,6 +66,7 @@ describe('InstallPluginsScreen', () => { }); it('auto-advances after install', async () => { + using _config = createConfigDir(); using project = createProjectDir(); using sut = renderApp({ screen: ScreenId.InstallPlugins, dir: project.path }); @@ -76,6 +82,7 @@ describe('InstallPluginsScreen', () => { }); it('shows continue option when plugins already installed', async () => { + using _config = createConfigDir(); const { detectInstalledPlugins } = await import('@spotify-confidence/core'); vi.mocked(detectInstalledPlugins).mockResolvedValueOnce([{ ide: 'claude', via: 'cli' }]); @@ -94,6 +101,7 @@ describe('InstallPluginsScreen', () => { }); it('sorts detected IDEs above non-detected ones', async () => { + using _config = createConfigDir(); const { detectInstalledPlugins } = await import('@spotify-confidence/core'); vi.mocked(detectInstalledPlugins).mockResolvedValueOnce([ { ide: 'claude', via: 'cli' }, @@ -112,7 +120,37 @@ describe('InstallPluginsScreen', () => { }); }); + it('auto-selects IDE from config and advances without user input', async () => { + using _config = createConfigDir({ ide: 'cursor' }); + using project = createProjectDir(); + using sut = renderApp({ screen: ScreenId.InstallPlugins, dir: project.path }); + + await waitFor(() => { + expect(sut.lastFrame()).toContain('Teach your AI Confidence'); + }); + }); + + it('saves IDE selection to config', async () => { + using config = createConfigDir(); + using project = createProjectDir(); + using sut = renderScreen(, { + screen: ScreenId.InstallPlugins, + dir: project.path, + }); + + await waitFor(() => { + expect(sut.lastFrame()).toContain('Claude Code'); + }); + + await act(() => sut.stdin.write(ENTER)); + + await waitFor(() => { + expect(config.readConfig()).toEqual(expect.objectContaining({ ide: 'claude' })); + }); + }); + it('shows error and retry option on install failure', async () => { + using _config = createConfigDir(); const { installPlugin } = await import('@spotify-confidence/core'); vi.mocked(installPlugin).mockRejectedValueOnce(new Error('Installation failed')); diff --git a/packages/quickstart/src/ui/lib/ide-config.ts b/packages/quickstart/src/ui/lib/ide-config.ts new file mode 100644 index 0000000..2b5ea0c --- /dev/null +++ b/packages/quickstart/src/ui/lib/ide-config.ts @@ -0,0 +1,22 @@ +import { getConfigValue, setConfigValue } from '@spotify-confidence/core'; +import type { IdeId } from '@spotify-confidence/shared-kernel'; + +const VALID_IDE_IDS: readonly IdeId[] = ['claude', 'cursor', 'codex']; + +export function readSavedIde(): IdeId | null { + try { + const saved = getConfigValue('ide'); + if (saved && VALID_IDE_IDS.includes(saved as IdeId)) return saved as IdeId; + } catch { + // config read failed — ignore + } + return null; +} + +export function saveIdeToConfig(ide: IdeId): void { + try { + setConfigValue('ide', ide); + } catch { + // config write failed — non-critical + } +} diff --git a/packages/quickstart/src/ui/screens/install-plugins/components/BottomPrompt.tsx b/packages/quickstart/src/ui/screens/install-plugins/components/BottomPrompt.tsx index cb86809..8e8f4d0 100644 --- a/packages/quickstart/src/ui/screens/install-plugins/components/BottomPrompt.tsx +++ b/packages/quickstart/src/ui/screens/install-plugins/components/BottomPrompt.tsx @@ -52,6 +52,7 @@ export function BottomPrompt({ phase, detected, onSelect, onError }: BottomPromp } case 'detecting': + case 'restoring': case 'installing': case 'updating': case 'done': diff --git a/packages/quickstart/src/ui/screens/install-plugins/components/MainContent.tsx b/packages/quickstart/src/ui/screens/install-plugins/components/MainContent.tsx index 042eb52..653e1f2 100644 --- a/packages/quickstart/src/ui/screens/install-plugins/components/MainContent.tsx +++ b/packages/quickstart/src/ui/screens/install-plugins/components/MainContent.tsx @@ -39,6 +39,7 @@ export function MainContent({ phase, detected, error }: MainContentProps) { )} {phase === 'detecting' && } + {phase === 'restoring' && } {phase === 'already-installed' && ( <> diff --git a/packages/quickstart/src/ui/screens/install-plugins/log-messages.ts b/packages/quickstart/src/ui/screens/install-plugins/log-messages.ts index 7f9a892..c183023 100644 --- a/packages/quickstart/src/ui/screens/install-plugins/log-messages.ts +++ b/packages/quickstart/src/ui/screens/install-plugins/log-messages.ts @@ -8,6 +8,10 @@ export function pluginInstalled(ide: string | null | undefined): LogMessage { return { input: ide ?? 'unknown', output: `Installed for ${ide}` }; } +export function pluginRestoredFromConfig(ide: string): LogMessage { + return { input: '(auto — from config)', output: ide }; +} + export function pluginExitedAfterError(error: string | null): LogMessage { return { input: 'Exit (after error)', output: error ?? 'Setup failed' }; } diff --git a/packages/quickstart/src/ui/screens/install-plugins/telemetry-events.ts b/packages/quickstart/src/ui/screens/install-plugins/telemetry-events.ts index ccc95e1..68f672f 100644 --- a/packages/quickstart/src/ui/screens/install-plugins/telemetry-events.ts +++ b/packages/quickstart/src/ui/screens/install-plugins/telemetry-events.ts @@ -21,6 +21,10 @@ export function pluginIdeSelected(ide: string): TelemetryEvent { return { step: 'install-plugins.ide', action: ide }; } +export function pluginIdeRestoredFromConfig(ide: string): TelemetryEvent { + return { step: 'install-plugins.ide', action: `restored:${ide}`, sentiment: 'positive' }; +} + export function pluginExitedAfterError(): TelemetryEvent { return { step: 'install-plugins.exit', action: 'exited-after-error', sentiment: 'frustrated' }; } diff --git a/packages/quickstart/src/ui/screens/install-plugins/useInitialDetection.ts b/packages/quickstart/src/ui/screens/install-plugins/useInitialDetection.ts index 53e80ab..59cba36 100644 --- a/packages/quickstart/src/ui/screens/install-plugins/useInitialDetection.ts +++ b/packages/quickstart/src/ui/screens/install-plugins/useInitialDetection.ts @@ -13,17 +13,21 @@ export function useInitialDetection(): InitialDetection { const session = useSession(); const [phase, setPhase] = useState(session.dryRun ? 'choose-ide' : 'detecting'); const [detected, setDetected] = useState([]); + const [savedIde] = useState(() => session.ide); - const applyResults = useCallback(function applyResults(found: InstalledPlugin[]) { - const ides = found.map((d) => d.ide); - setDetected(ides); - setPhase(found.length > 0 ? 'already-installed' : 'choose-ide'); + const applyResults = useCallback( + function applyResults(found: InstalledPlugin[]) { + const ides = found.map((d) => d.ide); + setDetected(ides); + setPhase(savedIde ? 'restoring' : found.length > 0 ? 'already-installed' : 'choose-ide'); - if (found.length > 0) { - store.setPluginTargets(ides); - store.setPluginInstallMethod(found[0].via); - } - }, []); + if (found.length > 0) { + store.setPluginTargets(ides); + store.setPluginInstallMethod(found[0].via); + } + }, + [savedIde], + ); useEffect( function resolveInitialDetection() { diff --git a/packages/quickstart/src/ui/screens/install-plugins/usePluginInstall.ts b/packages/quickstart/src/ui/screens/install-plugins/usePluginInstall.ts index 642bfee..68e6af0 100644 --- a/packages/quickstart/src/ui/screens/install-plugins/usePluginInstall.ts +++ b/packages/quickstart/src/ui/screens/install-plugins/usePluginInstall.ts @@ -1,12 +1,20 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { IdeId } from '@spotify-confidence/shared-kernel'; import { prepareIde, installPlugin, updatePlugin, track } from '@spotify-confidence/core'; import { $session, store } from '../../store.js'; import { useInitialDetection } from './useInitialDetection.js'; -import { pluginInstallFailed } from './telemetry-events.js'; +import { pluginInstallFailed, pluginIdeRestoredFromConfig } from './telemetry-events.js'; +import { saveIdeToConfig } from '../../lib/ide-config.js'; export type PluginPhase = - 'detecting' | 'already-installed' | 'choose-ide' | 'installing' | 'updating' | 'done' | 'error'; + | 'detecting' + | 'restoring' + | 'already-installed' + | 'choose-ide' + | 'installing' + | 'updating' + | 'done' + | 'error'; export type PluginInstallState = { phase: PluginPhase; @@ -22,40 +30,59 @@ export function usePluginInstall(): PluginInstallState { const phase = installPhase ?? initial.phase; - function selectIde(ide: IdeId) { - const isDetected = initial.detected.includes(ide); - - store.setIde(ide); - setInstallPhase(isDetected ? 'updating' : 'installing'); - - if ($session.get().dryRun) return setupDryRun(ide); - setupRealPlugin(ide, isDetected); - } - - function setupDryRun(ide: IdeId) { - setTimeout(() => { - store.setPluginTargets([ide]); - store.setPluginInstallMethod('download'); - setInstallPhase('done'); - }, 1000); - } - - function setupRealPlugin(ide: IdeId, shouldUpdate: boolean) { - const action = shouldUpdate ? updatePlugin : installPlugin; - - prepareIde(ide) - .then(() => action(ide, $session.get().projectDir)) - .then((method) => { - store.setPluginTargets([ide]); - store.setPluginInstallMethod(method); - setInstallPhase('done'); - }) - .catch((err) => { - setError(err instanceof Error ? err.message : 'Plugin setup failed'); - track(pluginInstallFailed()); - setInstallPhase('error'); - }); - } - - return { phase, detected: initial.detected, error, selectIde }; + const selectIde = useCallback( + function selectIde(ide: IdeId) { + const isDetected = initial.detected.includes(ide); + + store.setIde(ide); + saveIdeToConfig(ide); + setInstallPhase(isDetected ? 'updating' : 'installing'); + + if ($session.get().dryRun) { + setTimeout(() => { + store.setPluginTargets([ide]); + store.setPluginInstallMethod('download'); + setInstallPhase('done'); + }, 1000); + return; + } + + const action = isDetected ? updatePlugin : installPlugin; + + prepareIde(ide) + .then(() => action(ide, $session.get().projectDir)) + .then((method) => { + store.setPluginTargets([ide]); + store.setPluginInstallMethod(method); + setInstallPhase('done'); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Plugin setup failed'); + track(pluginInstallFailed()); + setInstallPhase('error'); + }); + }, + [initial.detected], + ); + + useEffect( + function autoSelectSavedIde() { + if (initial.phase !== 'restoring') return; + if (installPhase !== null) return; + + const ide = $session.get().ide; + if (!ide) return; + + track(pluginIdeRestoredFromConfig(ide)); + queueMicrotask(() => selectIde(ide)); + }, + [initial.phase, installPhase, selectIde], + ); + + return { + phase, + error, + detected: initial.detected, + selectIde, + }; } diff --git a/packages/quickstart/src/ui/store.ts b/packages/quickstart/src/ui/store.ts index 1eb77e8..6597eae 100644 --- a/packages/quickstart/src/ui/store.ts +++ b/packages/quickstart/src/ui/store.ts @@ -15,6 +15,7 @@ import { ScreenId, createSession, } from '@spotify-confidence/core'; +import { readSavedIde } from './lib/ide-config.js'; export type StoreOptions = { dryRun?: boolean; @@ -41,6 +42,7 @@ export const store = { debug: opts?.debug, dir: opts?.dir, goals: opts?.goals, + ide: opts?.dryRun ? null : readSavedIde(), }), ), diff --git a/packages/testing/src/scaffold/config-scaffold.ts b/packages/testing/src/scaffold/config-scaffold.ts new file mode 100644 index 0000000..ec084f7 --- /dev/null +++ b/packages/testing/src/scaffold/config-scaffold.ts @@ -0,0 +1,49 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +type ConfigValues = Record; + +const FILE_CONFIG = { encoding: 'utf-8', mode: 0o600 } as const; + +/** + * Creates an isolated config directory and sets `CONFIDENCE_CONFIG_DIR` + * to point to it. Optionally seeds `config.json` with initial values. + * Supports `Symbol.dispose` for automatic cleanup. + * + * @example + * ```ts + * using config = createConfigDir(); + * using config = createConfigDir({ ide: 'cursor' }); + * ``` + */ +export function createConfigDir(initial?: ConfigValues) { + const dir = mkdtempSync(join(tmpdir(), 'confidence-config-test-')); + const prev = process.env['CONFIDENCE_CONFIG_DIR']; + process.env['CONFIDENCE_CONFIG_DIR'] = dir; + + if (initial) { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'config.json'), JSON.stringify(initial, null, 2), FILE_CONFIG); + } + + return { + path: dir, + readConfig(): ConfigValues { + try { + return JSON.parse(readFileSync(join(dir, 'config.json'), 'utf-8')); + } catch { + return {}; + } + }, + [Symbol.dispose]() { + if (prev === undefined) delete process.env['CONFIDENCE_CONFIG_DIR']; + else process.env['CONFIDENCE_CONFIG_DIR'] = prev; + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + }, + }; +} diff --git a/packages/testing/src/scaffold/index.ts b/packages/testing/src/scaffold/index.ts index d981181..53eb232 100644 --- a/packages/testing/src/scaffold/index.ts +++ b/packages/testing/src/scaffold/index.ts @@ -1,2 +1,3 @@ +export { createConfigDir } from './config-scaffold.js'; export { createProjectDir } from './project-scaffold.js'; export type { ProjectType } from './types.js';