From cfa000ff4714ac5fee8897370320a38e9275ff9e Mon Sep 17 00:00:00 2001 From: bcotrim Date: Tue, 17 Mar 2026 19:28:02 +0000 Subject: [PATCH 1/2] Migrate appdata to ~/.studio/appdata.json with extensible migration framework --- apps/cli/commands/_migrate.ts | 54 ++++++++++++++++ apps/cli/index.ts | 18 ++++++ apps/cli/lib/appdata.ts | 31 +++++++-- apps/cli/lib/cli-config.ts | 9 ++- apps/cli/lib/tests/appdata.test.ts | 4 ++ apps/cli/lib/tests/snapshots.test.ts | 11 +++- apps/studio/src/index.ts | 3 + .../src/migrations/migrate-appdata-via-cli.ts | 21 +++++++ apps/studio/src/storage/paths.ts | 14 ++++- apps/studio/src/storage/storage-types.ts | 2 +- apps/studio/src/storage/user-data.ts | 21 +++++-- tools/common/lib/config-migrator.ts | 32 ++++++++++ .../common/lib/tests/config-migrator.test.ts | 63 +++++++++++++++++++ 13 files changed, 268 insertions(+), 15 deletions(-) create mode 100644 apps/cli/commands/_migrate.ts create mode 100644 apps/studio/src/migrations/migrate-appdata-via-cli.ts create mode 100644 tools/common/lib/config-migrator.ts create mode 100644 tools/common/lib/tests/config-migrator.test.ts diff --git a/apps/cli/commands/_migrate.ts b/apps/cli/commands/_migrate.ts new file mode 100644 index 0000000000..4a7606bc1b --- /dev/null +++ b/apps/cli/commands/_migrate.ts @@ -0,0 +1,54 @@ +/** + * Hidden migration command for Studio + * + * Copies appdata-v1.json from the platform-specific Electron location + * to ~/.studio/appdata.json. Called by Desktop on boot and as CLI middleware + * for standalone installations. + */ + +import fs from 'fs'; +import path from 'path'; +import { readFile, writeFile } from 'atomically'; +import { getAppdataDirectory } from 'cli/lib/appdata'; +import { STUDIO_CLI_HOME } from 'cli/lib/paths'; + +export function getNewAppdataPath(): string { + return path.join( STUDIO_CLI_HOME, 'appdata.json' ); +} + +function getOldAppdataPath(): string { + return path.join( getAppdataDirectory(), 'appdata-v1.json' ); +} + +export async function migrateAppdata(): Promise< void > { + const newPath = getNewAppdataPath(); + + if ( fs.existsSync( newPath ) ) { + return; + } + + const oldPath = getOldAppdataPath(); + + if ( ! fs.existsSync( oldPath ) ) { + return; + } + + const dir = path.dirname( newPath ); + if ( ! fs.existsSync( dir ) ) { + fs.mkdirSync( dir, { recursive: true } ); + } + + const content = await readFile( oldPath, { encoding: 'utf8' } ); + await writeFile( newPath, content, { encoding: 'utf8' } ); + + console.log( `Migrated appdata from ${ oldPath } to ${ newPath }` ); +} + +export async function commandHandler() { + try { + await migrateAppdata(); + } catch ( error ) { + console.error( 'Migration failed:', error ); + process.exitCode = 1; + } +} diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 3004d9a839..e5c5aede82 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -34,6 +34,15 @@ async function main() { return path.resolve( untildify( value ) ); }, } ) + .middleware( async () => { + try { + const { migrateAppdata } = await import( 'cli/commands/_migrate' ); + await migrateAppdata(); + } catch ( error ) { + // Migration failure should not block CLI usage + console.error( 'Appdata migration failed:', error ); + } + } ) .middleware( async ( argv ) => { if ( __ENABLE_CLI_TELEMETRY__ && ! argv.avoidTelemetry ) { try { @@ -153,6 +162,15 @@ async function main() { return eventsCommandHandler(); }, } ) + .command( { + command: '_migrate', + describe: false, // Hidden command + handler: async () => { + const { commandHandler: migrateCommandHandler } = await import( 'cli/commands/_migrate' ); + + return migrateCommandHandler(); + }, + } ) .demandCommand( 1, __( 'You must provide a valid command' ) ) .strict(); diff --git a/apps/cli/lib/appdata.ts b/apps/cli/lib/appdata.ts index 6d368d01fe..b49f7cb287 100644 --- a/apps/cli/lib/appdata.ts +++ b/apps/cli/lib/appdata.ts @@ -1,7 +1,8 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { LOCKFILE_NAME, LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; +import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; +import { applyMigrations, type ConfigMigration } from '@studio/common/lib/config-migrator'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; import { getAuthenticationUrl } from '@studio/common/lib/oauth'; import { snapshotSchema } from '@studio/common/types/snapshot'; @@ -9,6 +10,7 @@ import { __, sprintf } from '@wordpress/i18n'; import { readFile, writeFile } from 'atomically'; import { z } from 'zod'; import { validateAccessToken } from 'cli/lib/api'; +import { STUDIO_CLI_HOME } from 'cli/lib/paths'; import { LoggerError } from 'cli/logger'; import type { AiProviderId } from 'cli/ai/providers'; @@ -63,9 +65,23 @@ export function getAppdataPath(): string { if ( process.env.DEV_APP_DATA_PATH ) { return process.env.DEV_APP_DATA_PATH; } + + // Prefer new shared location (~/.studio/appdata.json) + const sharedPath = path.join( STUDIO_CLI_HOME, 'appdata.json' ); + if ( fs.existsSync( sharedPath ) ) { + return sharedPath; + } + + // Fall back to platform-specific location (older Desktop versions) return path.join( getAppdataDirectory(), 'appdata-v1.json' ); } +// Versioned data migrations for appdata.json. +// Add new migrations here with incrementing version numbers. +export const appdataMigrations: ConfigMigration[] = [ + // Example: { version: 2, migrate: ( data ) => { /* transform */ return data; } }, +]; + export async function readAppdata(): Promise< UserData > { const appDataPath = getAppdataPath(); @@ -75,7 +91,7 @@ export async function readAppdata(): Promise< UserData > { try { const fileContent = await readFile( appDataPath, { encoding: 'utf8' } ); - const userData = JSON.parse( fileContent ); + const userData = applyMigrations( JSON.parse( fileContent ), appdataMigrations ); return userDataSchema.parse( userData ); } catch ( error ) { if ( error instanceof LoggerError ) { @@ -118,14 +134,19 @@ export async function saveAppdata( userData: UserData ): Promise< void > { } } -const LOCKFILE_PATH = path.join( getAppdataDirectory(), LOCKFILE_NAME ); +function getAppdataLockfilePath(): string { + return path.join( path.dirname( getAppdataPath() ), 'appdata.json.lock' ); +} export async function lockAppdata(): Promise< void > { - await lockFileAsync( LOCKFILE_PATH, { wait: LOCKFILE_WAIT_TIME, stale: LOCKFILE_STALE_TIME } ); + await lockFileAsync( getAppdataLockfilePath(), { + wait: LOCKFILE_WAIT_TIME, + stale: LOCKFILE_STALE_TIME, + } ); } export async function unlockAppdata(): Promise< void > { - await unlockFileAsync( LOCKFILE_PATH ); + await unlockFileAsync( getAppdataLockfilePath() ); } export async function getAuthToken(): Promise< ValidatedAuthToken > { diff --git a/apps/cli/lib/cli-config.ts b/apps/cli/lib/cli-config.ts index c9a5a8771b..ad1c2082d4 100644 --- a/apps/cli/lib/cli-config.ts +++ b/apps/cli/lib/cli-config.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; +import { applyMigrations, type ConfigMigration } from '@studio/common/lib/config-migrator'; import { arePathsEqual, isWordPressDirectory } from '@studio/common/lib/fs-utils'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; import { siteDetailsSchema } from '@studio/common/lib/site-events'; @@ -46,6 +47,12 @@ export function getCliConfigPath(): string { return path.join( getCliConfigDirectory(), 'cli.json' ); } +// Versioned data migrations for cli.json. +// Add new migrations here with incrementing version numbers. +export const cliConfigMigrations: ConfigMigration[] = [ + // Example: { version: 2, migrate: ( data ) => { /* transform */ return data; } }, +]; + export async function readCliConfig(): Promise< CliConfig > { const configPath = getCliConfigPath(); @@ -56,7 +63,7 @@ export async function readCliConfig(): Promise< CliConfig > { try { const fileContent = await readFile( configPath, { encoding: 'utf8' } ); // eslint-disable-next-line no-var - var data = JSON.parse( fileContent ); + var data = applyMigrations( JSON.parse( fileContent ), cliConfigMigrations ); } catch ( error ) { throw new LoggerError( __( 'Failed to read CLI config file.' ), error ); } diff --git a/apps/cli/lib/tests/appdata.test.ts b/apps/cli/lib/tests/appdata.test.ts index 479d690dea..04b48f98dc 100644 --- a/apps/cli/lib/tests/appdata.test.ts +++ b/apps/cli/lib/tests/appdata.test.ts @@ -28,6 +28,7 @@ vi.mock( 'path', () => ( { join: vi.fn(), basename: vi.fn(), resolve: vi.fn(), + dirname: vi.fn(), }, } ) ); vi.mock( 'atomically', () => ( { @@ -52,6 +53,9 @@ describe( 'Appdata Module', () => { vi.mocked( path.join ).mockImplementation( ( ...args ) => args.join( '/' ) ); vi.mocked( path.basename ).mockReturnValue( mockSiteFolderName ); vi.mocked( path.resolve ).mockImplementation( ( path ) => path ); + vi.mocked( path.dirname ).mockImplementation( ( p ) => + p.split( '/' ).slice( 0, -1 ).join( '/' ) + ); vi.spyOn( Date, 'now' ).mockReturnValue( 1234567890 ); vi.mocked( fs.existsSync ).mockReturnValue( true ); diff --git a/apps/cli/lib/tests/snapshots.test.ts b/apps/cli/lib/tests/snapshots.test.ts index 7c824d090b..f3040bfaff 100644 --- a/apps/cli/lib/tests/snapshots.test.ts +++ b/apps/cli/lib/tests/snapshots.test.ts @@ -13,6 +13,9 @@ const mocks = vi.hoisted( () => ( { writeFile: vi.fn(), pathJoin: vi.fn().mockImplementation( ( ...args: string[] ) => args.join( '/' ) ), pathResolve: vi.fn().mockImplementation( ( path: string ) => path ), + pathDirname: vi + .fn() + .mockImplementation( ( p: string ) => p.split( '/' ).slice( 0, -1 ).join( '/' ) ), pathBasename: vi.fn(), lockfileLock: vi.fn().mockImplementation( ( path, options, callback ) => callback( null ) ), lockfileUnlock: vi.fn().mockImplementation( ( path, callback ) => callback( null ) ), @@ -28,9 +31,15 @@ vi.mock( 'fs', () => ( { } ) ); vi.mock( 'os', () => ( { default: { homedir: mocks.homedir }, homedir: mocks.homedir } ) ); vi.mock( 'path', () => ( { - default: { join: mocks.pathJoin, resolve: mocks.pathResolve, basename: mocks.pathBasename }, + default: { + join: mocks.pathJoin, + resolve: mocks.pathResolve, + dirname: mocks.pathDirname, + basename: mocks.pathBasename, + }, join: mocks.pathJoin, resolve: mocks.pathResolve, + dirname: mocks.pathDirname, basename: mocks.pathBasename, } ) ); vi.mock( 'atomically', () => ( { diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index 22b3948feb..3a407094ea 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -38,6 +38,7 @@ import { getSentryReleaseInfo } from 'src/lib/sentry-release'; import { startUserDataWatcher, stopUserDataWatcher } from 'src/lib/user-data-watcher'; import { setupLogging } from 'src/logging'; import { createMainWindow, getMainWindow } from 'src/main-window'; +import { migrateAppdataViaCli } from 'src/migrations/migrate-appdata-via-cli'; import { needsToMigrateFromWpNowFolder, migrateFromWpNowFolder, @@ -311,6 +312,8 @@ async function appBoot() { // WordPress server files are updated asynchronously to avoid delaying app initialization updateWPServerFiles().catch( Sentry.captureException ); + await migrateAppdataViaCli().catch( Sentry.captureException ); + if ( await needsToMigrateFromWpNowFolder() ) { await migrateFromWpNowFolder(); } diff --git a/apps/studio/src/migrations/migrate-appdata-via-cli.ts b/apps/studio/src/migrations/migrate-appdata-via-cli.ts new file mode 100644 index 0000000000..fddcd115e7 --- /dev/null +++ b/apps/studio/src/migrations/migrate-appdata-via-cli.ts @@ -0,0 +1,21 @@ +import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; + +/** + * Triggers the CLI `_migrate` command to copy appdata-v1.json from the + * platform-specific Electron location to ~/.studio/appdata.json. + */ +export async function migrateAppdataViaCli(): Promise< void > { + return new Promise< void >( ( resolve ) => { + const [ emitter ] = executeCliCommand( [ '_migrate' ], { output: 'ignore' } ); + + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', () => { + console.warn( 'CLI _migrate command failed. This may be expected with an older CLI.' ); + resolve(); + } ); + emitter.on( 'error', () => { + console.warn( 'CLI _migrate command errored. This may be expected with an older CLI.' ); + resolve(); + } ); + } ); +} diff --git a/apps/studio/src/storage/paths.ts b/apps/studio/src/storage/paths.ts index da9a50bde6..f32f417fea 100644 --- a/apps/studio/src/storage/paths.ts +++ b/apps/studio/src/storage/paths.ts @@ -1,5 +1,5 @@ +import os from 'os'; import path from 'path'; -import { LOCKFILE_NAME } from '@studio/common/constants'; function inChildProcess() { return process.env.STUDIO_IN_CHILD_PROCESS === 'true'; @@ -19,11 +19,19 @@ export function getUserDataFilePath(): string { if ( process.env.DEV_APP_DATA_PATH ) { return process.env.DEV_APP_DATA_PATH; } - return path.join( getAppDataPath(), getAppName(), 'appdata-v1.json' ); + return path.join( getStudioHome(), 'appdata.json' ); } export function getUserDataLockFilePath(): string { - return path.join( getAppDataPath(), getAppName(), LOCKFILE_NAME ); + return path.join( getStudioHome(), 'appdata.json.lock' ); +} + +function getStudioHome(): string { + if ( process.env.E2E && process.env.E2E_HOME_PATH ) { + return path.join( process.env.E2E_HOME_PATH, '.studio' ); + } + const homedir = app?.getPath( 'home' ) || os.homedir(); + return path.join( homedir, '.studio' ); } export function getServerFilesPath(): string { diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index c7d53e2299..ba8ff815d9 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -33,7 +33,7 @@ export interface UserData { } export interface PersistedUserData extends Omit< UserData, 'sites' > { - version: 1; + version: number; // Users can edit the file system manually which would make UserData['name'] and UserData['path'] // get out of sync. `name` is redundant because it can be calculated from `path`, so we diff --git a/apps/studio/src/storage/user-data.ts b/apps/studio/src/storage/user-data.ts index 6ca0ca6985..696fb59241 100644 --- a/apps/studio/src/storage/user-data.ts +++ b/apps/studio/src/storage/user-data.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import nodePath from 'node:path'; import * as Sentry from '@sentry/electron/main'; import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; +import { applyMigrations, type ConfigMigration } from '@studio/common/lib/config-migrator'; import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; import { sortSites } from '@studio/common/lib/sort-sites'; @@ -24,9 +25,13 @@ const migrateUserData = ( appName: string ) => { const newPath = getUserDataFilePath(); if ( fs.existsSync( oldPath ) && ! fs.existsSync( newPath ) ) { - fs.renameSync( oldPath, newPath ); + const dir = nodePath.dirname( newPath ); + if ( ! fs.existsSync( dir ) ) { + fs.mkdirSync( dir, { recursive: true } ); + } + fs.copyFileSync( oldPath, newPath ); console.log( - `Moved user data from ${ sanitizeUserpath( oldPath ) } to ${ sanitizeUserpath( newPath ) }` + `Copied user data from ${ sanitizeUserpath( oldPath ) } to ${ sanitizeUserpath( newPath ) }` ); } }; @@ -91,6 +96,14 @@ function legacyPopulateSnapshotUserIds( data: UserData ): void { } } +// Versioned data migrations for appdata.json. +// Add new migrations here with incrementing version numbers. +// Each migration receives the raw parsed JSON and returns transformed data. +export const appdataMigrations: ConfigMigration[] = [ + // Example for future use: + // { version: 2, migrate: ( data ) => { /* transform */ return data; } }, +]; + export async function loadUserData(): Promise< UserData > { migrateUserDataOldName(); const filePath = getUserDataFilePath(); @@ -98,8 +111,8 @@ export async function loadUserData(): Promise< UserData > { try { const asString = await readFile( filePath, 'utf-8' ); try { - const parsed = JSON.parse( asString ); - const data = fromDiskFormat( parsed ); + const parsed = applyMigrations( JSON.parse( asString ), appdataMigrations ); + const data = fromDiskFormat( parsed as unknown as PersistedUserData ); // Temporarily populate old snapshots with userId of authenticated user. // See PR #937 for more context. diff --git a/tools/common/lib/config-migrator.ts b/tools/common/lib/config-migrator.ts new file mode 100644 index 0000000000..6664bce877 --- /dev/null +++ b/tools/common/lib/config-migrator.ts @@ -0,0 +1,32 @@ +export type ConfigData = Record< string, unknown > & { version?: number }; + +export interface ConfigMigration { + version: number; + migrate: ( data: ConfigData ) => ConfigData; +} + +/** + * Applies pending migrations to config data based on its version field. + * Returns the transformed data with an updated version, or the original + * data unchanged if no migrations are needed. + * + * Migrations must have unique, ascending version numbers. + */ +export function applyMigrations( data: ConfigData, migrations: ConfigMigration[] ): ConfigData { + const currentVersion = data.version ?? 1; + const pending = migrations + .filter( ( m ) => m.version > currentVersion ) + .sort( ( a, b ) => a.version - b.version ); + + if ( pending.length === 0 ) { + return data; + } + + let result = { ...data }; + for ( const m of pending ) { + result = m.migrate( result ); + result.version = m.version; + } + + return result; +} diff --git a/tools/common/lib/tests/config-migrator.test.ts b/tools/common/lib/tests/config-migrator.test.ts new file mode 100644 index 0000000000..351803cfe2 --- /dev/null +++ b/tools/common/lib/tests/config-migrator.test.ts @@ -0,0 +1,63 @@ +import { applyMigrations, type ConfigMigration } from '@studio/common/lib/config-migrator'; + +describe( 'applyMigrations', () => { + it( 'returns data unchanged when no migrations exist', () => { + const data = { version: 1, foo: 'bar' }; + expect( applyMigrations( data, [] ) ).toEqual( data ); + } ); + + it( 'returns data unchanged when all migrations are older than current version', () => { + const data = { version: 3, foo: 'bar' }; + const migrations: ConfigMigration[] = [ + { version: 2, migrate: ( d ) => ( { ...d, upgraded: true } ) }, + ]; + expect( applyMigrations( data, migrations ) ).toEqual( data ); + } ); + + it( 'applies a single pending migration', () => { + const data = { version: 1, foo: 'bar' }; + const migrations: ConfigMigration[] = [ + { version: 2, migrate: ( d ) => ( { ...d, foo: 'baz' } ) }, + ]; + const result = applyMigrations( data, migrations ); + expect( result ).toEqual( { version: 2, foo: 'baz' } ); + } ); + + it( 'applies multiple migrations in order', () => { + const data = { version: 1, count: 0 }; + const migrations: ConfigMigration[] = [ + { version: 3, migrate: ( d ) => ( { ...d, count: ( d.count as number ) + 10 } ) }, + { version: 2, migrate: ( d ) => ( { ...d, count: ( d.count as number ) + 1 } ) }, + ]; + const result = applyMigrations( data, migrations ); + expect( result ).toEqual( { version: 3, count: 11 } ); + } ); + + it( 'only applies migrations newer than current version', () => { + const data = { version: 2, value: 'original' }; + const migrations: ConfigMigration[] = [ + { version: 2, migrate: ( d ) => ( { ...d, value: 'should-not-run' } ) }, + { version: 3, migrate: ( d ) => ( { ...d, value: 'v3' } ) }, + ]; + const result = applyMigrations( data, migrations ); + expect( result ).toEqual( { version: 3, value: 'v3' } ); + } ); + + it( 'defaults to version 1 when version field is missing', () => { + const data = { foo: 'bar' }; + const migrations: ConfigMigration[] = [ + { version: 2, migrate: ( d ) => ( { ...d, migrated: true } ) }, + ]; + const result = applyMigrations( data, migrations ); + expect( result ).toEqual( { version: 2, foo: 'bar', migrated: true } ); + } ); + + it( 'does not mutate the original data object', () => { + const data = { version: 1, foo: 'bar' }; + const migrations: ConfigMigration[] = [ + { version: 2, migrate: ( d ) => ( { ...d, foo: 'baz' } ) }, + ]; + applyMigrations( data, migrations ); + expect( data ).toEqual( { version: 1, foo: 'bar' } ); + } ); +} ); From aeb2e3a06bf8ef676fcb1cb710b854916b350871 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 18 Mar 2026 21:12:03 +0000 Subject: [PATCH 2/2] Move appdata migration from CLI to Studio Desktop --- apps/cli/commands/_migrate.ts | 54 ----------------- apps/cli/index.ts | 24 +++----- apps/cli/lib/appdata.ts | 9 +-- apps/cli/lib/studio-compatibility.ts | 30 ++++++++++ apps/studio/src/index.ts | 4 +- .../src/migrations/migrate-appdata-via-cli.ts | 59 +++++++++++++------ 6 files changed, 83 insertions(+), 97 deletions(-) delete mode 100644 apps/cli/commands/_migrate.ts create mode 100644 apps/cli/lib/studio-compatibility.ts diff --git a/apps/cli/commands/_migrate.ts b/apps/cli/commands/_migrate.ts deleted file mode 100644 index 4a7606bc1b..0000000000 --- a/apps/cli/commands/_migrate.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Hidden migration command for Studio - * - * Copies appdata-v1.json from the platform-specific Electron location - * to ~/.studio/appdata.json. Called by Desktop on boot and as CLI middleware - * for standalone installations. - */ - -import fs from 'fs'; -import path from 'path'; -import { readFile, writeFile } from 'atomically'; -import { getAppdataDirectory } from 'cli/lib/appdata'; -import { STUDIO_CLI_HOME } from 'cli/lib/paths'; - -export function getNewAppdataPath(): string { - return path.join( STUDIO_CLI_HOME, 'appdata.json' ); -} - -function getOldAppdataPath(): string { - return path.join( getAppdataDirectory(), 'appdata-v1.json' ); -} - -export async function migrateAppdata(): Promise< void > { - const newPath = getNewAppdataPath(); - - if ( fs.existsSync( newPath ) ) { - return; - } - - const oldPath = getOldAppdataPath(); - - if ( ! fs.existsSync( oldPath ) ) { - return; - } - - const dir = path.dirname( newPath ); - if ( ! fs.existsSync( dir ) ) { - fs.mkdirSync( dir, { recursive: true } ); - } - - const content = await readFile( oldPath, { encoding: 'utf8' } ); - await writeFile( newPath, content, { encoding: 'utf8' } ); - - console.log( `Migrated appdata from ${ oldPath } to ${ newPath }` ); -} - -export async function commandHandler() { - try { - await migrateAppdata(); - } catch ( error ) { - console.error( 'Migration failed:', error ); - process.exitCode = 1; - } -} diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 7560da0c50..f0c9b6070b 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -34,14 +34,15 @@ async function main() { return path.resolve( untildify( value ) ); }, } ) - .middleware( async () => { - try { - const { migrateAppdata } = await import( 'cli/commands/_migrate' ); - await migrateAppdata(); - } catch ( error ) { - // Migration failure should not block CLI usage - console.error( 'Appdata migration failed:', error ); + .middleware( async ( argv ) => { + // Skip check for internal commands triggered by Studio Desktop + const command = argv._[ 0 ]; + if ( command === '_events' ) { + return; } + + const { checkStudioCompatibility } = await import( 'cli/lib/studio-compatibility' ); + await checkStudioCompatibility(); } ) .middleware( async ( argv ) => { if ( __ENABLE_CLI_TELEMETRY__ && ! argv.avoidTelemetry ) { @@ -165,15 +166,6 @@ async function main() { return eventsCommandHandler(); }, } ) - .command( { - command: '_migrate', - describe: false, // Hidden command - handler: async () => { - const { commandHandler: migrateCommandHandler } = await import( 'cli/commands/_migrate' ); - - return migrateCommandHandler(); - }, - } ) .demandCommand( 1, __( 'You must provide a valid command' ) ) .strict(); diff --git a/apps/cli/lib/appdata.ts b/apps/cli/lib/appdata.ts index 989d6a776a..01ef9dd026 100644 --- a/apps/cli/lib/appdata.ts +++ b/apps/cli/lib/appdata.ts @@ -64,14 +64,7 @@ export function getAppdataPath(): string { return process.env.DEV_APP_DATA_PATH; } - // Prefer new shared location (~/.studio/appdata.json) - const sharedPath = path.join( STUDIO_CLI_HOME, 'appdata.json' ); - if ( fs.existsSync( sharedPath ) ) { - return sharedPath; - } - - // Fall back to platform-specific location (older Desktop versions) - return path.join( getAppdataDirectory(), 'appdata-v1.json' ); + return path.join( STUDIO_CLI_HOME, 'appdata.json' ); } // Versioned data migrations for appdata.json. diff --git a/apps/cli/lib/studio-compatibility.ts b/apps/cli/lib/studio-compatibility.ts new file mode 100644 index 0000000000..743230be69 --- /dev/null +++ b/apps/cli/lib/studio-compatibility.ts @@ -0,0 +1,30 @@ +import fs from 'fs'; +import path from 'path'; +import { __ } from '@wordpress/i18n'; +import { getAppdataDirectory } from 'cli/lib/appdata'; +import { STUDIO_CLI_HOME } from 'cli/lib/paths'; +import { LoggerError } from 'cli/logger'; + +/** + * Checks compatibility between the standalone CLI and the installed Studio Desktop app. + * + * If Studio Desktop is installed (platform-specific appdata exists) but the shared + * config at ~/.studio/appdata.json is missing, it means Studio hasn't been updated + * to a version that supports the shared location. In that case, prompt the user + * to update Studio. + */ +export async function checkStudioCompatibility(): Promise< void > { + const sharedAppdataPath = path.join( STUDIO_CLI_HOME, 'appdata.json' ); + if ( fs.existsSync( sharedAppdataPath ) ) { + return; + } + + const oldAppdataPath = path.join( getAppdataDirectory(), 'appdata-v1.json' ); + if ( fs.existsSync( oldAppdataPath ) ) { + throw new LoggerError( + __( + 'A newer version of Studio is required. Please update the Studio desktop app to continue using the CLI.' + ) + ); + } +} diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index 3a407094ea..04dbe14e32 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -38,7 +38,7 @@ import { getSentryReleaseInfo } from 'src/lib/sentry-release'; import { startUserDataWatcher, stopUserDataWatcher } from 'src/lib/user-data-watcher'; import { setupLogging } from 'src/logging'; import { createMainWindow, getMainWindow } from 'src/main-window'; -import { migrateAppdataViaCli } from 'src/migrations/migrate-appdata-via-cli'; +import { migrateAppdata } from 'src/migrations/migrate-appdata-via-cli'; import { needsToMigrateFromWpNowFolder, migrateFromWpNowFolder, @@ -312,7 +312,7 @@ async function appBoot() { // WordPress server files are updated asynchronously to avoid delaying app initialization updateWPServerFiles().catch( Sentry.captureException ); - await migrateAppdataViaCli().catch( Sentry.captureException ); + await migrateAppdata().catch( Sentry.captureException ); if ( await needsToMigrateFromWpNowFolder() ) { await migrateFromWpNowFolder(); diff --git a/apps/studio/src/migrations/migrate-appdata-via-cli.ts b/apps/studio/src/migrations/migrate-appdata-via-cli.ts index fddcd115e7..2d75a8accd 100644 --- a/apps/studio/src/migrations/migrate-appdata-via-cli.ts +++ b/apps/studio/src/migrations/migrate-appdata-via-cli.ts @@ -1,21 +1,46 @@ -import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; +/** + * Migrates appdata-v1.json from the platform-specific Electron location + * to ~/.studio/appdata.json on Desktop boot. + */ + +import fs from 'fs'; +import path from 'path'; +import { readFile, writeFile } from 'atomically'; +import { getUserDataFilePath } from 'src/storage/paths'; /** - * Triggers the CLI `_migrate` command to copy appdata-v1.json from the - * platform-specific Electron location to ~/.studio/appdata.json. + * Returns the old platform-specific appdata path used by previous Studio versions. + * macOS: ~/Library/Application Support/Studio/appdata-v1.json + * Windows: %APPDATA%\Studio\appdata-v1.json */ -export async function migrateAppdataViaCli(): Promise< void > { - return new Promise< void >( ( resolve ) => { - const [ emitter ] = executeCliCommand( [ '_migrate' ], { output: 'ignore' } ); - - emitter.on( 'success', () => resolve() ); - emitter.on( 'failure', () => { - console.warn( 'CLI _migrate command failed. This may be expected with an older CLI.' ); - resolve(); - } ); - emitter.on( 'error', () => { - console.warn( 'CLI _migrate command errored. This may be expected with an older CLI.' ); - resolve(); - } ); - } ); +function getOldAppdataPath(): string { + if ( process.platform === 'win32' ) { + return path.join( process.env.APPDATA || '', 'Studio', 'appdata-v1.json' ); + } + const os = require( 'os' ); + return path.join( os.homedir(), 'Library', 'Application Support', 'Studio', 'appdata-v1.json' ); +} + +export async function migrateAppdata(): Promise< void > { + const newPath = getUserDataFilePath(); + + if ( fs.existsSync( newPath ) ) { + return; + } + + const oldPath = getOldAppdataPath(); + + if ( ! fs.existsSync( oldPath ) ) { + return; + } + + const dir = path.dirname( newPath ); + if ( ! fs.existsSync( dir ) ) { + fs.mkdirSync( dir, { recursive: true } ); + } + + const content = await readFile( oldPath, { encoding: 'utf8' } ); + await writeFile( newPath, content, { encoding: 'utf8' } ); + + console.log( `Migrated appdata from ${ oldPath } to ${ newPath }` ); }