Skip to content
Draft
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
54 changes: 54 additions & 0 deletions apps/cli/commands/_migrate.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
18 changes: 18 additions & 0 deletions apps/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();

Expand Down
31 changes: 26 additions & 5 deletions apps/cli/lib/appdata.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();

Expand All @@ -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 ) {
Expand Down Expand Up @@ -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 > {
Expand Down
9 changes: 8 additions & 1 deletion apps/cli/lib/cli-config/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand All @@ -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 );
}
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/lib/tests/appdata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ vi.mock( 'path', () => ( {
join: vi.fn(),
basename: vi.fn(),
resolve: vi.fn(),
dirname: vi.fn(),
},
} ) );
vi.mock( 'atomically', () => ( {
Expand All @@ -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 );
Expand Down
11 changes: 10 additions & 1 deletion apps/cli/lib/tests/snapshots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) ),
Expand All @@ -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', () => ( {
Expand Down
3 changes: 3 additions & 0 deletions apps/studio/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}
Expand Down
21 changes: 21 additions & 0 deletions apps/studio/src/migrations/migrate-appdata-via-cli.ts
Original file line number Diff line number Diff line change
@@ -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();
} );
} );
}
14 changes: 11 additions & 3 deletions apps/studio/src/storage/paths.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/src/storage/storage-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 17 additions & 4 deletions apps/studio/src/storage/user-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 ) }`
);
}
};
Expand Down Expand Up @@ -78,15 +83,23 @@ 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();

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 );
Expand Down
32 changes: 32 additions & 0 deletions tools/common/lib/config-migrator.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading