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 866f55a95e..7560da0c50 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 { @@ -156,6 +165,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 8c37818fc9..989d6a776a 100644 --- a/apps/cli/lib/appdata.ts +++ b/apps/cli/lib/appdata.ts @@ -1,13 +1,15 @@ 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 { __, 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'; @@ -61,9 +63,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(); @@ -73,7 +89,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 ) { @@ -116,14 +132,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/core.ts b/apps/cli/lib/cli-config/core.ts index bdae5c5ea6..efa7760f8e 100644 --- a/apps/cli/lib/cli-config/core.ts +++ b/apps/cli/lib/cli-config/core.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; import { siteDetailsSchema } from '@studio/common/lib/cli-events'; +import { applyMigrations, type ConfigMigration } from '@studio/common/lib/config-migrator'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; import { snapshotSchema } from '@studio/common/types/snapshot'; import { __ } from '@wordpress/i18n'; @@ -48,6 +49,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(); @@ -58,7 +65,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 289a0d6303..3b27de3d56 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 e1aa47d2bb..35a0adb0f0 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -31,7 +31,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 4c0162b9a9..f4607132bb 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 ) }` ); } }; @@ -78,6 +83,14 @@ function populatePhpVersion( sites: SiteDetails[] ) { } ); } +// 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(); @@ -85,8 +98,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 ); sortSites( data.sites ); populatePhpVersion( data.sites ); 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' } ); + } ); +} );