Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 8 additions & 59 deletions packages/cli/src/features/config/config.ts
Original file line number Diff line number Diff line change
@@ -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<Record<ConfigKey, string>>;

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';
60 changes: 60 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -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<Record<ConfigKey, string>>;

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(', ')}`);
}
}
8 changes: 8 additions & 0 deletions packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export {
readConfig,
getConfigValue,
setConfigValue,
resetConfig,
isValidKey,
validKeys,
} from './config.js';
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
3 changes: 2 additions & 1 deletion packages/core/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export function createSession(opts?: {
debug?: boolean;
dir?: string;
goals?: OnboardingGoal[];
ide?: IdeId | null;
}): WizardSession {
return {
sessionId: randomUUID(),
Expand All @@ -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: [],
Expand Down
30 changes: 30 additions & 0 deletions packages/quickstart/__tests__/e2e/saved-ide.e2e.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -43,13 +45,15 @@ export function createSession({
env = {},
token,
refreshToken = 'e2e-refresh-token',
config,
systemPath,
}: {
project?: ProjectType;
extraArgs?: string[];
env?: Record<string, string>;
token?: string;
refreshToken?: string | null;
config?: Record<string, string>;
systemPath?: string;
} = {}): TerminalSession {
const mockBinDir = process.env.E2E_MOCK_BIN_DIR!;
Expand All @@ -60,15 +64,9 @@ export function createSession({
...env,
};

if (token) {
if (token || config) {
const configDir = mkdtempSync(join(tmpdir(), 'e2e-config-'));

const credentials: Record<string, string> = { 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;
}

Expand All @@ -81,3 +79,22 @@ export function createSession({
session.addTempDir(projectDir);
return session;
}

function seedConfigDir(
dir: string,
opts: {
token?: string;
refreshToken?: string | null;
config?: Record<string, string>;
},
): void {
if (opts.token) {
const credentials: Record<string, string> = { 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');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { store } from '@ui/store.js';
vi.mock('@spotify-confidence/core', async (importOriginal) => ({
...(await importOriginal<typeof import('@spotify-confidence/core')>()),
launchChatSession: vi.fn(),
getConfigValue: vi.fn().mockReturnValue(undefined),
}));

describe('DoneScreen', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -20,13 +21,15 @@ vi.mock('@spotify-confidence/core', async (importOriginal) => ({

describe('InstallPluginsScreen', () => {
it('renders title', async () => {
using _config = createConfigDir();
using sut = renderScreen(<InstallPluginsScreen />, { screen: ScreenId.InstallPlugins });
await waitFor(() => {
expect(sut.lastFrame()).toContain('Select agent to set up');
});
});

it('shows IDE selection when no plugins detected', async () => {
using _config = createConfigDir();
using sut = renderScreen(<InstallPluginsScreen />, { screen: ScreenId.InstallPlugins });
await waitFor(() => {
expect(sut.lastFrame()).toContain('Claude Code');
Expand All @@ -36,13 +39,15 @@ describe('InstallPluginsScreen', () => {
});

it('shows file system access warning during IDE selection', async () => {
using _config = createConfigDir();
using sut = renderScreen(<InstallPluginsScreen />, { screen: ScreenId.InstallPlugins });
await waitFor(() => {
expect(sut.lastFrame()).toContain('full file system access');
});
});

it('installs plugin and shows success', async () => {
using _config = createConfigDir();
using project = createProjectDir();
using sut = renderScreen(<InstallPluginsScreen />, {
screen: ScreenId.InstallPlugins,
Expand All @@ -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 });

Expand All @@ -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' }]);

Expand All @@ -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' },
Expand All @@ -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(<InstallPluginsScreen />, {
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'));

Expand Down
22 changes: 22 additions & 0 deletions packages/quickstart/src/ui/lib/ide-config.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function BottomPrompt({ phase, detected, onSelect, onError }: BottomPromp
}

case 'detecting':
case 'restoring':
case 'installing':
case 'updating':
case 'done':
Expand Down
Loading
Loading