Skip to content

Commit cc9b462

Browse files
youknowriadclaudefredrikekelund
authored
Share connected WP.com sites between Studio app and CLI (#3227)
## Related issues - Related to # ## How AI was used in this PR Opus 4.7 scoped the refactor, wrote the new shared helpers, the migration, and the Desktop/CLI wiring. I (the author) steered the data-model decision (cli.json sites property, per-user keying preserved) and reviewed each hunk. The diff is roughly 520 lines added / 135 removed across 12 files — mostly mechanical once the data layout was settled. ## Proposed Changes Connected WP.com sites used to live in `app.json` as a top-level `connectedWpcomSites: { [userId]: SyncSite[] }` — Desktop-only state. This moves the list into per-site entries in `cli.json` so both Desktop and the CLI share a single source of truth, and wires up the CLI push/pull flows to auto-connect on success. - **Shared data layer** (`tools/common/`) - `syncSiteSchema` zod schema added next to the `SyncSite` type, which is now inferred from it. - New `tools/common/lib/connected-sites.ts` with read/write helpers against `cli.json`: `getConnectedWpcomSitesForLocalSite`, `getAllConnectedWpcomSitesForCurrentUser`, `addConnectedWpcomSite`, `removeConnectedWpcomSite`, `updateConnectedWpcomSites`, `markConnectedWpcomSiteSynced`. Uses the existing `cli.json.lock` lockfile and a permissive passthrough schema so Desktop and CLI can evolve their `cli.json` site entries independently without corrupting each other's fields. - **Storage schemas** - CLI `siteSchema` now carries an optional `connectedWpcomSites: { [userId]: SyncSite[] }` map per site. - `connectedWpcomSites` removed from the `app.json` `UserData` type. - **Migration 04** copies `app.json.connectedWpcomSites[userId][].{localSiteId}` into `cli.json sites[].connectedWpcomSites[userId]`, deduping by remote id. It stamps `connectedWpcomSitesMigratedToCli: true` on `app.json` and leaves the legacy field in place for this release so older Studio versions keep working — a follow-up migration will strip it. - **Desktop IPC handlers** now delegate through the shared helpers instead of reading/writing `app.json`: - `connectWpcomSites`, `disconnectWpcomSites`, `updateConnectedWpcomSites`, `getConnectedWpcomSites` in `apps/studio/src/modules/sync/lib/ipc-handlers.ts` - `reconcileSessionEnvironmentBeforeRun` and `setSessionEnvironment` in `apps/studio/src/ipc-handlers.ts` - **CLI auto-connect**: `apps/cli/commands/push.ts` and `apps/cli/commands/pull.ts` call `addConnectedWpcomSite` + `markConnectedWpcomSiteSynced` after a successful run. - **AI agent push workflow**: the `studio code` agent gained a `site_connected_remote_sites` MCP tool plus a new system-prompt section instructing it to resolve the target before pushing — 1 attached site → confirm, many → `AskUserQuestion` list, 0 → open-ended question. ## Testing Instructions - Back up `~/.studio/app.json` and `~/.studio/cli.json`. - **Migration:** with `app.json.connectedWpcomSites` populated, launch the Desktop app. Expect `cli.json sites[].connectedWpcomSites[userId]` to be populated, and `app.json.connectedWpcomSitesMigratedToCli: true` to be added. The legacy `app.json.connectedWpcomSites` should still be present. - **Desktop:** open a site with a connection — publish picker + site dropdown + session environment switcher should show the connection as before. - **Desktop connect/disconnect:** from the sync modal, connect and disconnect a WordPress.com site — the entry should appear/disappear in `cli.json` without further app.json writes. - **CLI auto-connect:** run `studio push` or `studio pull` for a site with no connections yet — after success, verify the remote site shows up under `cli.json sites[].connectedWpcomSites` and that Desktop's site dropdown reflects it. - **CLI AI agent:** run `studio code`, ask it to "push my site". It should call `site_connected_remote_sites`, then either confirm (single attached), show a picker (many), or ask open-ended (none) before calling `site_push`. ## Pre-merge Checklist - [x] Have you checked for TypeScript, React or other console errors? (`npm run typecheck` clean across workspaces; `npm test -- apps/cli apps/studio` — 1042 passing) - [ ] Manual Desktop smoke test of connect/disconnect UI - [ ] Manual CLI push/pull to verify auto-connect 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Fredrik Rombach Ekelund <fredrik.rombach.ekelund@automattic.com>
1 parent a10f45e commit cc9b462

24 files changed

Lines changed: 696 additions & 265 deletions

apps/cli/ai/system-prompt.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ One \`Write\` or \`Edit\` per turn (read-only \`site_info\`, \`site_list\`, \`wp
183183
- take_screenshot: Take a full-page screenshot of a URL (supports desktop and mobile viewports). Use this to visually check the site after building it.
184184
- need_for_speed: Measure frontend performance metrics (TTFB, FCP, LCP, CLS, page weight, DOM size, JS/CSS/image/font asset breakdown) for a running site. Use this to identify performance bottlenecks and guide optimization.
185185
- rank_me_up: Run an on-page SEO audit (title/meta tags, headings, image alt text, OpenGraph/Twitter cards, JSON-LD structured data, robots.txt and sitemap.xml availability) for a running site. Use this to identify on-page SEO issues and guide fixes.
186+
- site_connected_remote_sites: List the WordPress.com sites already attached to a local site. Call this before site_push to decide how to ask the user which remote site to target.
186187
- site_push: Push a local site to a WordPress.com site. Requires authentication (studio auth login). Specify the remote site URL or ID and sync options (all, sqls, uploads, plugins, themes, contents).
187188
- site_pull: Pull a WordPress.com site to a local site. Requires authentication. Specify the remote site URL or ID and sync options.
188189
- site_import: Import a backup file (.zip, .tar.gz, .sql, .wpress) into a local site.
@@ -198,7 +199,18 @@ One \`Write\` or \`Edit\` per turn (read-only \`site_info\`, \`site_list\`, \`wp
198199
- Always enqueue the theme's style.css on the frontend from functions.php.
199200
- For theme and page content custom CSS, put the styles in the main style.css of the theme. No custom stylesheets.
200201
- Scroll animations must use progressive enhancement: CSS defines elements in their **final visible state** by default (full opacity, final position). JavaScript on the frontend adds the initial hidden state (e.g. \`opacity: 0\`, \`transform\`) and scroll-triggered transitions. This ensures elements are fully visible in the block editor (which loads theme CSS but not custom JS).
201-
- All animations and transitions must respect \`prefers-reduced-motion\`. Add a \`@media (prefers-reduced-motion: reduce)\` block that disables or simplifies animations (e.g. \`animation: none; transition: none; scroll-behavior: auto;\`).`;
202+
- All animations and transitions must respect \`prefers-reduced-motion\`. Add a \`@media (prefers-reduced-motion: reduce)\` block that disables or simplifies animations (e.g. \`animation: none; transition: none; scroll-behavior: auto;\`).
203+
204+
## Push workflow
205+
206+
When the user asks to push a site to WordPress.com, you MUST resolve the target remote site before calling \`site_push\`:
207+
208+
1. Call \`site_connected_remote_sites\` with the local site's name or path to get the list of already-attached WordPress.com sites.
209+
2. Branch on how many remote sites are attached:
210+
- **Exactly one attached site**: Use \`AskUserQuestion\` to confirm pushing to that site. Present two options labeled "Yes" and "No" with a description that includes the remote site's name and URL. Only call \`site_push\` if the user confirms.
211+
- **Multiple attached sites**: Use \`AskUserQuestion\` with one question whose options are the attached sites (label = site name, description = URL). Then call \`site_push\` with the chosen site's ID or URL as \`remoteSite\`.
212+
- **No attached sites**: Do NOT use \`AskUserQuestion\`. Ask an open-ended question in plain text for the URL or ID of the WordPress.com site to push to, then wait for the user's reply before calling \`site_push\`.
213+
3. Never call \`site_push\` without explicit user confirmation of the target — even when only one site is attached.`;
202214
}
203215

204216
const REMOTE_SESSION_GUIDANCE = `## Telegram remote session

apps/cli/ai/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { deleteSiteTool } from './delete-site';
77
import { exportSiteTool } from './export-site';
88
import { importSiteTool } from './import-site';
99
import { installTaxonomyScriptsTool } from './install-taxonomy-scripts';
10+
import { listConnectedRemoteSitesTool } from './list-connected-remote-sites';
1011
import { listPreviewsTool } from './list-previews';
1112
import { listSitesTool } from './list-sites';
1213
import { auditPerformanceTool } from './need-for-speed';
@@ -52,6 +53,7 @@ export const studioToolDefinitions = [
5253
installTaxonomyScriptsTool,
5354
auditPerformanceTool,
5455
auditSeoTool,
56+
listConnectedRemoteSitesTool,
5557
pushSiteTool,
5658
pullSiteTool,
5759
importSiteTool,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { tool } from '@anthropic-ai/claude-agent-sdk';
2+
import { getConnectedWpcomSitesForLocalSite } from '@studio/common/lib/connected-sites';
3+
import { z } from 'zod/v4';
4+
import { errorResult, resolveSite, textResult } from './utils';
5+
6+
export const listConnectedRemoteSitesTool = tool(
7+
'site_connected_remote_sites',
8+
'Lists the WordPress.com sites that are already connected/attached to a local Studio site. ' +
9+
'Use this before calling site_push to determine how to ask the user which remote site to push to. ' +
10+
'Returns an empty array when the user has no connections for that local site.',
11+
{
12+
nameOrPath: z.string().describe( 'The local site name or file system path' ),
13+
},
14+
async ( args ) => {
15+
try {
16+
const site = await resolveSite( args.nameOrPath );
17+
const connected = await getConnectedWpcomSitesForLocalSite( site.id );
18+
const summary = connected.map( ( s ) => ( {
19+
id: s.id,
20+
name: s.name,
21+
url: s.url,
22+
isStaging: s.isStaging,
23+
syncSupport: s.syncSupport,
24+
lastPushTimestamp: s.lastPushTimestamp,
25+
lastPullTimestamp: s.lastPullTimestamp,
26+
} ) );
27+
return textResult( JSON.stringify( summary, null, 2 ) );
28+
} catch ( error ) {
29+
return errorResult(
30+
`Failed to list connected remote sites: ${
31+
error instanceof Error ? error.message : String( error )
32+
}`
33+
);
34+
}
35+
}
36+
);

apps/cli/commands/pull.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import fs from 'fs';
22
import os from 'os';
33
import path from 'path';
44
import { confirm } from '@inquirer/prompts';
5+
import {
6+
addConnectedWpcomSite,
7+
markConnectedWpcomSiteSynced,
8+
} from '@studio/common/lib/connected-sites';
59
import { readAuthToken } from '@studio/common/lib/shared-config';
610
import {
711
SYNC_MAX_STALLED_ATTEMPTS,
@@ -189,6 +193,15 @@ export async function runCommand(
189193
const siteUrl = getSiteUrl( site );
190194
await fetch( siteUrl ).catch( () => {} );
191195

196+
// Remember this connection so future push/pull runs (and the Desktop UI)
197+
// can surface it without re-selecting from the full site list.
198+
try {
199+
await addConnectedWpcomSite( site.id, { ...remoteSite, localSiteId: site.id } );
200+
await markConnectedWpcomSiteSynced( site.id, remoteSite.id, 'pull' );
201+
} catch ( error ) {
202+
logger.reportError( new LoggerError( 'Failed to save connected site', error ), false );
203+
}
204+
192205
logger.reportSuccess(
193206
sprintf( __( 'Pulled from %s (%s)' ), remoteSite.name, remoteSite.url )
194207
);

apps/cli/commands/push.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import fs from 'fs';
22
import os from 'os';
33
import path from 'path';
4+
import {
5+
addConnectedWpcomSite,
6+
markConnectedWpcomSiteSynced,
7+
} from '@studio/common/lib/connected-sites';
48
import { createDeployIgnoreFilter } from '@studio/common/lib/deploy-ignore';
59
import { readAuthToken } from '@studio/common/lib/shared-config';
610
import {
@@ -251,6 +255,15 @@ export async function runCommand(
251255
);
252256
}
253257

258+
// Remember this connection so future push/pull runs (and the Desktop UI)
259+
// can surface it without re-selecting from the full site list.
260+
try {
261+
await addConnectedWpcomSite( site.id, { ...remoteSite, localSiteId: site.id } );
262+
await markConnectedWpcomSiteSynced( site.id, remoteSite.id, 'push' );
263+
} catch ( error ) {
264+
logger.reportError( new LoggerError( 'Failed to save connected site', error ), false );
265+
}
266+
254267
logger.reportSuccess(
255268
sprintf( __( 'Successfully pushed to %s (%s)' ), remoteSite.name, remoteSite.url )
256269
);
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Copies `connectedWpcomSites` from the Desktop-owned `app.json` into the
3+
* shared `shared.json` so the CLI can read and write it through the helpers
4+
* in `tools/common/lib/connected-sites.ts`.
5+
*
6+
* Runs only when app.json carries the legacy field AND shared.json does not
7+
* already have it — the CLI must never modify app.json. The Studio app runs
8+
* its own migration to strip the field once it boots; until then the legacy
9+
* copy in app.json is harmless because both the new Studio and the new CLI
10+
* read connections from shared.json.
11+
*/
12+
13+
import fs from 'node:fs';
14+
import {
15+
lockSharedConfig,
16+
readSharedConfig,
17+
saveSharedConfig,
18+
unlockSharedConfig,
19+
} from '@studio/common/lib/shared-config';
20+
import { getAppConfigPath } from '@studio/common/lib/well-known-paths';
21+
import { syncSiteSchema } from '@studio/common/types/sync';
22+
import { readFile } from 'atomically';
23+
import { z } from 'zod';
24+
import type { Migration } from '@studio/common/lib/migration';
25+
26+
const appConnectedShapeSchema = z
27+
.object( {
28+
connectedWpcomSites: z.record( z.string(), z.array( syncSiteSchema ) ).optional(),
29+
} )
30+
.loose();
31+
32+
async function readAppConnectedSites(): Promise< Record<
33+
string,
34+
z.infer< typeof syncSiteSchema >[]
35+
> | null > {
36+
const appPath = getAppConfigPath();
37+
if ( ! fs.existsSync( appPath ) ) {
38+
return null;
39+
}
40+
try {
41+
const raw = await readFile( appPath, { encoding: 'utf8' } );
42+
const parsed = appConnectedShapeSchema.safeParse( JSON.parse( raw ) );
43+
if ( ! parsed.success ) {
44+
return null;
45+
}
46+
return parsed.data.connectedWpcomSites ?? null;
47+
} catch {
48+
return null;
49+
}
50+
}
51+
52+
export const migrateConnectedSitesToShared: Migration = {
53+
async needsToRun() {
54+
const appConnected = await readAppConnectedSites();
55+
if ( ! appConnected || Object.keys( appConnected ).length === 0 ) {
56+
return false;
57+
}
58+
const shared = await readSharedConfig().catch( () => null );
59+
return ! shared?.connectedWpcomSites;
60+
},
61+
62+
async run() {
63+
const appConnected = await readAppConnectedSites();
64+
if ( ! appConnected || Object.keys( appConnected ).length === 0 ) {
65+
return;
66+
}
67+
try {
68+
await lockSharedConfig();
69+
const shared = await readSharedConfig();
70+
if ( shared.connectedWpcomSites ) {
71+
return;
72+
}
73+
await saveSharedConfig( { ...shared, connectedWpcomSites: appConnected } );
74+
} finally {
75+
await unlockSharedConfig();
76+
}
77+
},
78+
};

apps/cli/migrations/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { checkStudioCompatibilityForInitialMigration } from './00-check-studio-c
22
import { hideStudioDirWindows } from './01-hide-studio-dir-windows';
33
import { renameProcessManagerHome } from './03-rename-pm-home';
44
import { cleanupObsoleteServerFiles } from './04-cleanup-obsolete-server-files';
5+
import { migrateConnectedSitesToShared } from './05-migrate-connected-sites-to-shared';
56
import type { Migration } from '@studio/common/lib/migration';
67

78
export const migrations: Migration[] = [
89
checkStudioCompatibilityForInitialMigration,
910
hideStudioDirWindows,
1011
renameProcessManagerHome,
1112
cleanupObsoleteServerFiles,
13+
migrateConnectedSitesToShared,
1214
];

apps/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"build:npm": "vite build --config ./vite.config.npm.ts",
6666
"install:bundle": "npm install --omit=dev --no-package-lock --no-progress --install-links --no-workspaces && patch-package && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
6767
"package": "npm run install:bundle && npm run build:prod",
68+
"lint": "eslint .",
6869
"watch": "vite build --config ./vite.config.dev.ts --watch",
6970
"prepublishOnly": "npm run build:npm",
7071
"postinstall": "node scripts/postinstall-npm.mjs",

apps/studio/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"make:linux-x64": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && electron-forge make . --arch=x64 --platform=linux",
2222
"make:linux-arm64": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && electron-forge make . --arch=arm64 --platform=linux",
2323
"install:bundle": "npm install --no-package-lock --no-progress --install-links --no-workspaces && patch-package && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
24+
"lint": "eslint src e2e",
2425
"package": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && electron-forge package .",
2526
"publish": "electron-forge publish .",
2627
"start-wayland": "npm -w wp-studio run build && electron-forge start . -- --enable-features=UseOzonePlatform --ozone-platform=wayland",

apps/studio/src/ipc-handlers.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from '@studio/common/lib/agent-skills';
3636
import { validateBlueprintData } from '@studio/common/lib/blueprint-validation';
3737
import { parseCliError, errorMessageContains } from '@studio/common/lib/cli-error';
38+
import { getConnectedWpcomSitesForLocalSite } from '@studio/common/lib/connected-sites';
3839
import { createDeployIgnoreFilter } from '@studio/common/lib/deploy-ignore';
3940
import { extractZip } from '@studio/common/lib/extract-zip';
4041
import {
@@ -52,11 +53,7 @@ import { isMultisite } from '@studio/common/lib/is-multisite';
5253
import { getAuthenticationUrl } from '@studio/common/lib/oauth';
5354
import { decodePassword, encodePassword } from '@studio/common/lib/passwords';
5455
import { sanitizeFolderName } from '@studio/common/lib/sanitize-folder-name';
55-
import {
56-
getCurrentUserId,
57-
readSharedConfig,
58-
updateSharedConfig,
59-
} from '@studio/common/lib/shared-config';
56+
import { readSharedConfig, updateSharedConfig } from '@studio/common/lib/shared-config';
6057
import { SYNC_IGNORE_DEFAULTS } from '@studio/common/lib/sync/constants';
6158
import { shouldExcludeFromSync } from '@studio/common/lib/sync/exclude-from-sync';
6259
import { shouldLimitDepth } from '@studio/common/lib/sync/tree-utils';
@@ -270,12 +267,7 @@ async function reconcileSessionEnvironmentBeforeRun( sessionId: string ): Promis
270267
return;
271268
}
272269

273-
const currentUserId = await getCurrentUserId();
274-
const userData = currentUserId ? await loadUserData() : undefined;
275-
const connected = userData?.connectedWpcomSites?.[ currentUserId! ] ?? [];
276-
const connectedForOwner = connected.filter(
277-
( site ) => site.localSiteId === ownerServer.details.id
278-
);
270+
const connectedForOwner = await getConnectedWpcomSitesForLocalSite( ownerServer.details.id );
279271
const connectedIds = new Set( connectedForOwner.map( ( site ) => site.id ) );
280272

281273
const effective = deriveEffectiveEnvironment( summary, ( blogId ) => connectedIds.has( blogId ) );
@@ -376,10 +368,7 @@ export async function setSessionEnvironment(
376368
const timestamp = new Date().toISOString();
377369

378370
if ( environment === 'live' ) {
379-
const currentUserId = await getCurrentUserId();
380-
const userData = currentUserId ? await loadUserData() : undefined;
381-
const connected = userData?.connectedWpcomSites?.[ currentUserId! ] ?? [];
382-
const candidates = connected.filter( ( s ) => s.localSiteId === ownerServer.details.id );
371+
const candidates = await getConnectedWpcomSitesForLocalSite( ownerServer.details.id );
383372
// Prefer the production (non-staging) site to match the UI's
384373
// `pickLiveSite` behavior in the site dropdown.
385374
const liveSite = candidates.find( ( s ) => ! s.isStaging ) ?? candidates[ 0 ];

0 commit comments

Comments
 (0)