Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cdb311a
Read site data from CLI instead of appdata (#2701)
bcotrim Mar 11, 2026
9e72f3f
Merge remote-tracking branch 'origin/trunk' into stu-1350-decoupled-c…
bcotrim Mar 12, 2026
8daf4a9
Merge branch 'trunk' into stu-1350-decoupled-config-dev
bcotrim Mar 13, 2026
b2ce79e
Move CLI site data to dedicated config file (#2731)
bcotrim Mar 13, 2026
7748599
Merge remote-tracking branch 'origin/trunk' into stu-1350-decoupled-c…
bcotrim Mar 13, 2026
a87533b
Merge trunk into stu-1350-decoupled-config-dev
bcotrim Mar 13, 2026
b4b6e42
Merge branch 'trunk' into stu-1350-decoupled-config-dev
bcotrim Mar 13, 2026
304a3da
Merge branch 'trunk' into stu-1350-decoupled-config-dev
bcotrim Mar 16, 2026
163b971
Fix AI tools test mocks to use cli-config instead of appdata
bcotrim Mar 16, 2026
147bd6d
Merge trunk and resolve conflicts for cli-config decoupling
bcotrim Mar 17, 2026
ae5be29
Apply decoupled config strategy to preview sites (#2807)
bcotrim Mar 17, 2026
12b98f1
Merge branch 'trunk' into stu-1350-decoupled-config-dev
bcotrim Mar 18, 2026
1081ea7
Wire auth and locale to shared.json, AI settings to cli.json (#2821)
bcotrim Mar 18, 2026
a1b7c38
Merge trunk and resolve conflicts in _events.ts and user-settings ipc…
bcotrim Mar 19, 2026
23d18ed
Merge branch 'trunk' into stu-1350-decoupled-config-dev
bcotrim Mar 20, 2026
6e95cb5
Merge branch 'trunk' into stu-1350-decoupled-config-dev
bcotrim Mar 20, 2026
0d2e271
Migrate appdata to ~/.studio/appdata.json with versioned migrations (…
bcotrim Mar 23, 2026
77b1ed1
Merge trunk and resolve conflict in ai/ui.ts site data lookup
bcotrim Mar 23, 2026
7be1ebc
Fix lint errors: update AI imports from appdata to cli-config
bcotrim Mar 23, 2026
5680975
trigger ci
bcotrim Mar 23, 2026
72a7bae
Exclude AI config fields from app.json and enforce shared config locking
bcotrim Mar 23, 2026
7f60923
Move getSiteByFolder outside try/finally to prevent unlock without lock
bcotrim Mar 23, 2026
cc27588
Wrap saveSharedConfig test calls with lock/unlock to satisfy lint rule
bcotrim Mar 23, 2026
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
2 changes: 1 addition & 1 deletion apps/cli/__mocks__/lib/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { vi } from 'vitest';

export const connectToDaemon = vi.fn().mockResolvedValue( undefined );
export const disconnectFromDaemon = vi.fn().mockResolvedValue( undefined );
export const emitSiteEvent = vi.fn().mockResolvedValue( undefined );
export const emitCliEvent = vi.fn().mockResolvedValue( undefined );
export const killDaemonAndChildren = vi.fn().mockResolvedValue( undefined );
export const listProcesses = vi.fn().mockResolvedValue( [] );
export const getDaemonBus = vi.fn().mockResolvedValue( {} );
Expand Down
6 changes: 3 additions & 3 deletions apps/cli/ai/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
getAiProviderDefinition,
type AiProviderId,
} from 'cli/ai/providers';
import { getAiProvider, saveAiProvider } from 'cli/lib/appdata';
import { readCliConfig, updateCliConfigWithPartial } from 'cli/lib/cli-config/core';

async function getPreferredReadyProvider(
exclude?: AiProviderId
Expand Down Expand Up @@ -49,7 +49,7 @@ export async function resolveUnavailableAiProvider(
}

export async function resolveInitialAiProvider(): Promise< AiProviderId > {
const savedProvider = await getAiProvider();
const { aiProvider: savedProvider } = await readCliConfig();
if ( savedProvider ) {
const definition = getAiProviderDefinition( savedProvider );
if (
Expand All @@ -73,7 +73,7 @@ export async function resolveInitialAiProvider(): Promise< AiProviderId > {
}

export async function saveSelectedAiProvider( provider: AiProviderId ): Promise< void > {
await saveAiProvider( provider );
await updateCliConfigWithPartial( { aiProvider: provider } );
}

export async function prepareAiProvider(
Expand Down
29 changes: 15 additions & 14 deletions apps/cli/ai/providers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import childProcess from 'child_process';
import { password } from '@inquirer/prompts';
import { readAuthToken } from '@studio/common/lib/shared-config';
import { __ } from '@wordpress/i18n';
import { z } from 'zod';
import { getAnthropicApiKey, getAuthToken, saveAnthropicApiKey } from 'cli/lib/appdata';
import { readCliConfig, updateCliConfigWithPartial } from 'cli/lib/cli-config/core';
import { LoggerError } from 'cli/logger';

export const AI_PROVIDERS = {
Expand All @@ -13,7 +13,6 @@ export const AI_PROVIDERS = {

export type AiProviderId = keyof typeof AI_PROVIDERS;

export const aiProviderSchema = z.enum( [ 'wpcom', 'anthropic-claude', 'anthropic-api-key' ] );
export const DEFAULT_AI_PROVIDER: AiProviderId = 'anthropic-api-key';
export const AI_PROVIDER_PRIORITY: AiProviderId[] = [
'wpcom',
Expand Down Expand Up @@ -51,7 +50,7 @@ export function hasClaudeCodeAuth(): boolean {
async function resolveAnthropicApiKey( options?: {
force?: boolean;
} ): Promise< string | undefined > {
const savedKey = await getAnthropicApiKey();
const { anthropicApiKey: savedKey } = await readCliConfig();
if ( savedKey && ! options?.force ) {
return savedKey;
}
Expand All @@ -67,7 +66,7 @@ async function resolveAnthropicApiKey( options?: {
},
} );

await saveAnthropicApiKey( apiKey );
await updateCliConfigWithPartial( { anthropicApiKey: apiKey } );
return apiKey;
}

Expand All @@ -83,12 +82,8 @@ function getWpcomAiGatewayBaseUrl(): string {
}

async function hasValidWpcomAuth(): Promise< boolean > {
try {
await getAuthToken();
return true;
} catch {
return false;
}
const token = await readAuthToken();
return token !== null;
}

function createBaseEnvironment(): Record< string, string > {
Expand Down Expand Up @@ -117,7 +112,10 @@ const AI_PROVIDER_DEFINITIONS: Record< AiProviderId, AiProviderDefinition > = {
throw new LoggerError( __( 'WordPress.com login required. Use /login to authenticate.' ) );
},
resolveEnv: async () => {
const token = await getAuthToken();
const token = await readAuthToken();
if ( ! token ) {
throw new LoggerError( __( 'WordPress.com login required. Use /login to authenticate.' ) );
}
const env = createBaseEnvironment();
env.ANTHROPIC_BASE_URL = getWpcomAiGatewayBaseUrl();
env.ANTHROPIC_AUTH_TOKEN = token.accessToken;
Expand Down Expand Up @@ -156,12 +154,15 @@ const AI_PROVIDER_DEFINITIONS: Record< AiProviderId, AiProviderDefinition > = {
id: 'anthropic-api-key',
autoFallbackWhenUnavailable: false,
isVisible: async () => true,
isReady: async () => Boolean( await getAnthropicApiKey() ),
isReady: async () => {
const { anthropicApiKey } = await readCliConfig();
return Boolean( anthropicApiKey );
},
prepare: async ( options ) => {
await resolveAnthropicApiKey( options );
},
resolveEnv: async () => {
const apiKey = await getAnthropicApiKey();
const { anthropicApiKey: apiKey } = await readCliConfig();
if ( ! apiKey ) {
throw new LoggerError(
__(
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/ai/sessions/paths.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from 'path';
import { getAppdataDirectory } from 'cli/lib/appdata';
import { getAppdataDirectory } from 'cli/lib/server-files';

// Keep month/day segments zero-padded so directory names sort chronologically.
function formatDatePart( value: number ): string {
Expand Down
103 changes: 67 additions & 36 deletions apps/cli/ai/tests/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import childProcess from 'child_process';
import { password } from '@inquirer/prompts';
import { readAuthToken } from '@studio/common/lib/shared-config';
import { vi } from 'vitest';
import {
getAvailableAiProviders,
Expand All @@ -9,12 +10,7 @@ import {
resolveInitialAiProvider,
resolveUnavailableAiProvider,
} from 'cli/ai/auth';
import {
getAiProvider,
getAnthropicApiKey,
getAuthToken,
saveAnthropicApiKey,
} from 'cli/lib/appdata';
import { readCliConfig, updateCliConfigWithPartial } from 'cli/lib/cli-config/core';
import { LoggerError } from 'cli/logger';

vi.mock( 'child_process', () => ( {
Expand All @@ -28,33 +24,40 @@ vi.mock( '@inquirer/prompts', () => ( {
password: vi.fn(),
} ) );

vi.mock( 'cli/lib/appdata', () => ( {
getAiProvider: vi.fn(),
getAnthropicApiKey: vi.fn(),
getAuthToken: vi.fn(),
saveAnthropicApiKey: vi.fn(),
saveAiProvider: vi.fn(),
vi.mock( '@studio/common/lib/shared-config', () => ( {
readAuthToken: vi.fn(),
} ) );

vi.mock( 'cli/lib/cli-config/core', () => ( {
readCliConfig: vi.fn().mockResolvedValue( { version: 1, sites: [] } ),
updateCliConfigWithPartial: vi.fn(),
} ) );

describe( 'AI auth helpers', () => {
beforeEach( () => {
vi.resetAllMocks();
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
delete process.env.WPCOM_AI_PROXY_BASE_URL;
} );

it( 'uses the saved Anthropic API key when provider is Anthropic API key', async () => {
vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'saved-key' );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
anthropicApiKey: 'saved-key',
} );

const env = await resolveAiEnvironment( 'anthropic-api-key' );

expect( env.ANTHROPIC_API_KEY ).toBe( 'saved-key' );
expect( env.ANTHROPIC_BASE_URL ).toBeUndefined();
expect( env.ANTHROPIC_AUTH_TOKEN ).toBeUndefined();
expect( saveAnthropicApiKey ).not.toHaveBeenCalled();
expect( updateCliConfigWithPartial ).not.toHaveBeenCalled();
} );

it( 'requires a saved Anthropic API key in API key mode', async () => {
vi.mocked( getAnthropicApiKey ).mockResolvedValue( undefined );
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );

await expect( resolveAiEnvironment( 'anthropic-api-key' ) ).rejects.toBeInstanceOf(
LoggerError
Expand All @@ -72,23 +75,30 @@ describe( 'AI auth helpers', () => {
} );

it( 'prompts for the API key immediately when preparing the API key provider', async () => {
vi.mocked( getAnthropicApiKey ).mockResolvedValue( undefined );
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( password ).mockResolvedValue( 'prompted-key' );

await prepareAiProvider( 'anthropic-api-key' );

expect( password ).toHaveBeenCalledOnce();
expect( saveAnthropicApiKey ).toHaveBeenCalledWith( 'prompted-key' );
expect( updateCliConfigWithPartial ).toHaveBeenCalledWith( {
anthropicApiKey: 'prompted-key',
} );
} );

it( 'can force re-entering the API key even when one is already saved', async () => {
vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'saved-key' );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
anthropicApiKey: 'saved-key',
} );
vi.mocked( password ).mockResolvedValue( 'updated-key' );

await prepareAiProvider( 'anthropic-api-key', { force: true } );

expect( password ).toHaveBeenCalledOnce();
expect( saveAnthropicApiKey ).toHaveBeenCalledWith( 'updated-key' );
expect( updateCliConfigWithPartial ).toHaveBeenCalledWith( { anthropicApiKey: 'updated-key' } );
} );

it( 'lists Claude auth only when it is available', async () => {
Expand All @@ -106,7 +116,7 @@ describe( 'AI auth helpers', () => {
} );

it( 'configures the WP.com gateway environment', async () => {
vi.mocked( getAuthToken ).mockResolvedValue( {
vi.mocked( readAuthToken ).mockResolvedValue( {
accessToken: 'wpcom-token',
displayName: 'User',
email: 'user@example.com',
Expand All @@ -127,15 +137,26 @@ describe( 'AI auth helpers', () => {
} );

it( 'prefers the saved provider', async () => {
vi.mocked( getAiProvider ).mockResolvedValue( 'anthropic-api-key' );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
aiProvider: 'anthropic-api-key',
anthropicApiKey: 'key',
} );

await expect( resolveInitialAiProvider() ).resolves.toBe( 'anthropic-api-key' );
expect( getAuthToken ).not.toHaveBeenCalled();
expect( readAuthToken ).not.toHaveBeenCalled();
} );

it( 'falls back to API key mode when saved Claude auth is no longer available', async () => {
vi.mocked( getAiProvider ).mockResolvedValue( 'anthropic-claude' );
vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
aiProvider: 'anthropic-claude',
} );
vi.mocked( readAuthToken ).mockResolvedValue( null );
vi.mocked( childProcess.execFileSync ).mockImplementation( () => {
throw new Error( 'not authenticated' );
} );
Expand All @@ -144,16 +165,21 @@ describe( 'AI auth helpers', () => {
} );

it( 'falls back from saved WP.com provider when WordPress.com auth is unavailable and Claude auth is ready', async () => {
vi.mocked( getAiProvider ).mockResolvedValue( 'wpcom' );
vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
aiProvider: 'wpcom',
} );
vi.mocked( readAuthToken ).mockResolvedValue( null );
vi.mocked( childProcess.execFileSync ).mockReturnValue( 'Authenticated' as never );

await expect( resolveInitialAiProvider() ).resolves.toBe( 'anthropic-claude' );
} );

it( 'defaults to WP.com when no provider is saved and a valid WP.com token exists', async () => {
vi.mocked( getAiProvider ).mockResolvedValue( undefined );
vi.mocked( getAuthToken ).mockResolvedValue( {
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( readAuthToken ).mockResolvedValue( {
accessToken: 'wpcom-token',
displayName: 'User',
email: 'user@example.com',
Expand All @@ -166,8 +192,8 @@ describe( 'AI auth helpers', () => {
} );

it( 'falls back to Anthropic API key when no other auth is available', async () => {
vi.mocked( getAiProvider ).mockResolvedValue( undefined );
vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) );
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( readAuthToken ).mockResolvedValue( null );
vi.mocked( childProcess.execFileSync ).mockImplementation( () => {
throw new Error( 'not authenticated' );
} );
Expand All @@ -176,15 +202,15 @@ describe( 'AI auth helpers', () => {
} );

it( 'defaults to Claude auth when no provider is saved and Claude auth is available', async () => {
vi.mocked( getAiProvider ).mockResolvedValue( undefined );
vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) );
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( readAuthToken ).mockResolvedValue( null );
vi.mocked( childProcess.execFileSync ).mockReturnValue( 'Authenticated' as never );

await expect( resolveInitialAiProvider() ).resolves.toBe( 'anthropic-claude' );
} );

it( 'reports WordPress.com readiness based on WP.com auth state', async () => {
vi.mocked( getAuthToken ).mockResolvedValue( {
vi.mocked( readAuthToken ).mockResolvedValue( {
accessToken: 'wpcom-token',
displayName: 'User',
email: 'user@example.com',
Expand All @@ -195,13 +221,18 @@ describe( 'AI auth helpers', () => {

await expect( isAiProviderReady( 'wpcom' ) ).resolves.toBe( true );

vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) );
vi.mocked( readAuthToken ).mockResolvedValue( null );
await expect( isAiProviderReady( 'wpcom' ) ).resolves.toBe( false );
} );

it( 'resolves a fallback provider only for providers that auto-fallback', async () => {
vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) );
vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'saved-key' );
vi.mocked( readAuthToken ).mockResolvedValue( null );
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
anthropicApiKey: 'saved-key',
} );

await expect( resolveUnavailableAiProvider( 'wpcom' ) ).resolves.toBe( 'anthropic-api-key' );
await expect( resolveUnavailableAiProvider( 'anthropic-api-key' ) ).resolves.toBeUndefined();
Expand Down
16 changes: 10 additions & 6 deletions apps/cli/ai/tests/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { runCommand as runCreatePreviewCommand } from 'cli/commands/preview/crea
import { runCommand as runDeletePreviewCommand } from 'cli/commands/preview/delete';
import { runCommand as runListPreviewCommand } from 'cli/commands/preview/list';
import { runCommand as runUpdatePreviewCommand } from 'cli/commands/preview/update';
import { getSiteByFolder, readAppdata } from 'cli/lib/appdata';
import { readCliConfig } from 'cli/lib/cli-config/core';
import { getSiteByFolder } from 'cli/lib/cli-config/sites';
import { getProgressCallback, setProgressCallback } from 'cli/logger';
import { studioToolDefinitions } from '../tools';

Expand Down Expand Up @@ -56,10 +57,13 @@ vi.mock( 'cli/commands/site/stop', () => ( {
runCommand: vi.fn(),
} ) );

vi.mock( 'cli/lib/appdata', async () => ( {
...( await vi.importActual( 'cli/lib/appdata' ) ),
vi.mock( 'cli/lib/cli-config/core', async () => ( {
...( await vi.importActual( 'cli/lib/cli-config/core' ) ),
readCliConfig: vi.fn(),
} ) );
vi.mock( 'cli/lib/cli-config/sites', async () => ( {
...( await vi.importActual( 'cli/lib/cli-config/sites' ) ),
getSiteByFolder: vi.fn(),
readAppdata: vi.fn(),
} ) );

vi.mock( 'cli/lib/daemon-client', () => ( {
Expand Down Expand Up @@ -97,9 +101,9 @@ describe( 'Studio AI MCP tools', () => {
vi.resetAllMocks();
process.exitCode = undefined;
setProgressCallback( null );
vi.mocked( readAppdata ).mockResolvedValue( {
vi.mocked( readCliConfig ).mockResolvedValue( {
sites: [ mockSite ],
} as Awaited< ReturnType< typeof readAppdata > > );
} as Awaited< ReturnType< typeof readCliConfig > > );
vi.mocked( getSiteByFolder ).mockResolvedValue( mockSite );
} );

Expand Down
Loading
Loading