Skip to content

Migrate appdata to ~/.studio/appdata.json with versioned migrations#2836

Open
bcotrim wants to merge 20 commits intostu-1350-decoupled-config-devfrom
stu-1350-decoupled-config-dev-v3
Open

Migrate appdata to ~/.studio/appdata.json with versioned migrations#2836
bcotrim wants to merge 20 commits intostu-1350-decoupled-config-devfrom
stu-1350-decoupled-config-dev-v3

Conversation

@bcotrim
Copy link
Contributor

@bcotrim bcotrim commented Mar 17, 2026

Related issues

Related to STU-1350

How AI was used in this PR

All code was generated via Claude Code (Opus + Haiku). Changes were reviewed and validated through iterative conversation with the user.

Proposed Changes

  • CLI _migrate command — Hidden command that copies appdata-v1.json from platform-specific Electron path to ~/.studio/appdata.json
  • Desktop bootstrap — Triggers CLI migration on boot before other data operations
  • Updated primary paths — Desktop and CLI both read from ~/.studio/appdata.json with CLI fallback to old platform-specific location
  • Generic migration framework — New ConfigMigrator in tools/common/lib/ that applies versioned migrations based on file version field
  • Wired into config readers — Appdata, CLI config, and shared config all use the migration runner for future extensibility

Testing Instructions

  1. npm run typecheck — Verify no type errors
  2. npm test — All 1314 tests pass (7 new tests for config migrator)
  3. Manual: Launch Desktop with existing appdata-v1.json → Verify ~/.studio/appdata.json is created and Desktop reads from it
  4. Manual: Add/modify a site → Verify ~/.studio/appdata.json is updated
  5. Manual: CLI studio site list → Verify reads from ~/.studio/appdata.json
  6. Manual: Restart Desktop → Verify migration is idempotent (no errors)

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors?

@bcotrim bcotrim self-assigned this Mar 17, 2026
Comment on lines +37 to +45
.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 );
}
} )
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Studio app should be responsible for migrating the data (or at least pushing the trigger). If the standalone CLI does it automatically, then we cannot guarantee that the installed version of Studio can handle the current config setup. That's because theoretically, someone can have Studio 1.7.6 installed and install and run the standalone CLI. This code will make the standalone CLI run the migration on every run.

I think this logic should be a check rather than a migration. It should run for every command except _migrate. If the Studio app is installed and ~/.studio/shared.json is missing, it should return an error message prompting the user to update Studio. If Studio isn't installed, just proceed. If Studio is installed and the shared config exists, just proceed.

@bcotrim bcotrim marked this pull request as ready for review March 18, 2026 23:23
Copy link
Contributor

@fredrikekelund fredrikekelund left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The basics of the migration works, but we need to address the fact that we still expect the appdata file to contain a sites array in some places.

return process.env.DEV_APP_DATA_PATH;
}
return path.join( getAppDataPath(), getAppName(), 'appdata-v1.json' );
return path.join( getStudioHome(), 'appdata.json' );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to keep calling it "appdata" or maybe just "app"? I don't really have a preference, but since we're calling the CLI config file "cli.json", I guess there's an argument for "app.json" being a good fit…

@@ -28,7 +28,7 @@ export interface UserData {
}

export interface PersistedUserData extends Omit< UserData, 'sites' > {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I commented about this on Slack, too, but appdata.json no longer contains any sites.

Comment on lines +127 to +139
function pickAppdataSiteFields( site: Record< string, unknown > ): Record< string, unknown > {
const result: Record< string, unknown > = {};
for ( const key of Object.keys( site ) ) {
if ( ! excludedSiteFields.has( key ) ) {
result[ key ] = site[ key ];
}
}
// Always keep id so we can correlate with cli.json sites
if ( site.id ) {
result.id = site.id;
}
return result;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what this function is for

Comment on lines +150 to +160
if ( Array.isArray( oldData.sites ) ) {
const sitesWithDesktopFields = oldData.sites
.map( ( site: Record< string, unknown > ) => pickAppdataSiteFields( site ) )
.filter( ( site: Record< string, unknown > ) =>
Object.keys( site ).some( ( key ) => key !== 'id' && site[ key ] !== undefined )
);

if ( sitesWithDesktopFields.length > 0 ) {
config.sites = sitesWithDesktopFields;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The appdata file shouldn't contain any sites, though, right?

Comment on lines +82 to +84
function buildSharedConfig( oldData: Record< string, unknown > ): Record< string, unknown > {
return { version: 1, ...pick( oldData, sharedConfigFields ) };
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function buildSharedConfig( oldData: Record< string, unknown > ): Record< string, unknown > {
return { version: 1, ...pick( oldData, sharedConfigFields ) };
}
function buildSharedConfig( oldData: Record< string, unknown > ): Record< string, unknown > {
const schema = z.object( { ...sharedConfigSchema.omit( { version: true } ).shape } );
const parsed = schema.parse( oldData );
return { version: 1, ...parsed };
}

I think it'd be a good idea to lean into zod parsing here, too. If oldData doesn't pass validation, then it would cause an error the next time the CLI or the app tries to read that file.

I had to play around with the schema definition a little, but this suggestion works. We want to get rid of the .loose() call from the original definition – that's why we jump through the extra hoops

Comment on lines +98 to +102
if ( Array.isArray( oldData.sites ) ) {
config.sites = oldData.sites.map( ( site: Record< string, unknown > ) =>
pick( site, cliSiteFields )
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if ( Array.isArray( oldData.sites ) ) {
config.sites = oldData.sites.map( ( site: Record< string, unknown > ) =>
pick( site, cliSiteFields )
);
}
const cliSiteSchema = siteDetailsSchema.extend( {
url: z.string().optional(),
latestCliPid: z.number().optional(),
} );
if ( Array.isArray( oldData.sites ) ) {
config.sites = oldData.sites.map( ( site: Record< string, unknown > ) =>
cliSiteSchema.parse( site )
);
}

Same thing here about leaning into zod parsing, because that's the schema we expect the file to conform to later, anyway.

Comment on lines +111 to +125
/**
* Fields from the old appdata that stay in the new appdata.json.
* Sites keep only Desktop-specific fields (themeDetails, sortOrder).
*
* We derive the "keep" set by excluding fields that moved to shared.json or cli.json.
*/
const movedTopLevelFields = new Set( [ ...sharedConfigFields, 'sites', 'snapshots', 'version' ] );

// Per-site fields that stay in appdata.json: anything NOT in the CLI site schema,
// NOT in the snapshot schema, and NOT runtime state.
const excludedSiteFields = new Set( [
...cliSiteFields,
...Object.keys( snapshotSchema.shape ),
'running',
] );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe running was ever stored anywhere – it comes from the runtime type. As for themeDetails, we need to figure something out…

We might consider adding an array or a record of themeDetails entries to appdata.json. WDYT? It would take some plumbing to get right and to ensure we clean it up when a site is deleted, but it shouldn't be overly complex.

@bcotrim
Copy link
Contributor Author

bcotrim commented Mar 19, 2026

@fredrikekelund thanks for the review!
We still need to store some sites information in the app.json file (themeDetails and sortOrder)
I renamed the field to siteMetadata to make the intention clearer. Let me know how it looks.

@bcotrim
Copy link
Contributor Author

bcotrim commented Mar 19, 2026

@fredrikekelund E2E tests should be fixed now. I intentionally left the appdata-v1 setup logic in E2E tests so the migration is tested on all tests. We can later decide to keep it or update the E2E to setup the new config files instead.

@bcotrim bcotrim requested a review from fredrikekelund March 20, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants