@@ -12,7 +12,7 @@ import type { UpdatePostInstallDoctorResult } from "../infra/update-doctor-resul
1212import type { RuntimeEnv } from "../runtime.js" ;
1313import { normalizeHealthCheck } from "./health-check-adapter.js" ;
1414import type { HealthCheckInput , RunnableHealthCheck } from "./health-check-runner-types.js" ;
15- import type { HealthCheck , HealthFinding } from "./health-checks.js" ;
15+ import type { HealthCheck , HealthFinding , HealthRepairEffect } from "./health-checks.js" ;
1616import type { FlowContribution } from "./types.js" ;
1717
1818type DoctorFlowMode = "local" | "remote" ;
@@ -46,6 +46,7 @@ export type DoctorHealthFlowContext = {
4646 gatewayStatus ?: import ( "../commands/status.types.js" ) . StatusSummary ;
4747 gatewayMemoryProbe ?: Awaited < ReturnType < typeof probeGatewayMemoryStatus > > ;
4848 postInstallDoctorResult ?: UpdatePostInstallDoctorResult ;
49+ repairEffects ?: HealthRepairEffect [ ] ;
4950} ;
5051
5152type DoctorHealthContribution = FlowContribution & {
@@ -1229,27 +1230,38 @@ async function runWriteConfigHealth(ctx: DoctorHealthFlowContext): Promise<void>
12291230 command : "doctor" ,
12301231 mode : resolveDoctorMode ( ctx . cfg ) ,
12311232 } ) ;
1232- if ( shouldSkipLegacyUpdateDoctorConfigWrite ( { env : ctx . env ?? process . env } ) ) {
1233- ctx . runtime . log ( "Skipping doctor config write during legacy update handoff." ) ;
1234- return ;
1235- }
12361233 const legacyParentVersionOverride = isLegacyParentWritableUpdateDoctorPass (
12371234 ctx . env ?? process . env ,
12381235 )
12391236 ? ctx . configResult . sourceLastTouchedVersion ?. trim ( ) || ctx . cfg . meta ?. lastTouchedVersion
12401237 : undefined ;
1238+ const writeOptions = {
1239+ allowConfigSizeDrop : ctx . configResult . shouldWriteConfig === true || updateDoctorRun ,
1240+ skipPluginValidation :
1241+ ctx . configResult . skipPluginValidationOnWrite === true || updateDoctorRun ,
1242+ preservedLegacyRootKeys : ctx . configResult . preservedLegacyRootKeys ,
1243+ ...( legacyParentVersionOverride
1244+ ? { lastTouchedVersionOverride : legacyParentVersionOverride }
1245+ : { } ) ,
1246+ } ;
1247+ if ( shouldSkipLegacyUpdateDoctorConfigWrite ( { env : ctx . env ?? process . env } ) ) {
1248+ ctx . runtime . log ( "Skipping doctor config write during legacy update handoff." ) ;
1249+ return ;
1250+ }
1251+ if ( ctx . options . dryRun === true ) {
1252+ const preview = buildConfigWriteDryRunPreview ( {
1253+ ctx,
1254+ updateDoctorRun,
1255+ writeOptions,
1256+ } ) ;
1257+ ctx . runtime . log ( preview . message ) ;
1258+ ctx . repairEffects = [ ...( ctx . repairEffects ?? [ ] ) , preview . effect ] ;
1259+ return ;
1260+ }
12411261 await replaceConfigFile ( {
12421262 nextConfig : ctx . cfg ,
12431263 afterWrite : { mode : "auto" } ,
1244- writeOptions : {
1245- allowConfigSizeDrop : ctx . configResult . shouldWriteConfig === true || updateDoctorRun ,
1246- skipPluginValidation :
1247- ctx . configResult . skipPluginValidationOnWrite === true || updateDoctorRun ,
1248- preservedLegacyRootKeys : ctx . configResult . preservedLegacyRootKeys ,
1249- ...( legacyParentVersionOverride
1250- ? { lastTouchedVersionOverride : legacyParentVersionOverride }
1251- : { } ) ,
1252- } ,
1264+ writeOptions,
12531265 } ) ;
12541266 logConfigUpdated ( ctx . runtime ) ;
12551267 const preUpdateSnapshotPath = `${ ctx . configPath } .pre-update` ;
@@ -1269,6 +1281,78 @@ async function runWriteConfigHealth(ctx: DoctorHealthFlowContext): Promise<void>
12691281 }
12701282}
12711283
1284+ function buildConfigWriteDryRunPreview ( params : {
1285+ ctx : DoctorHealthFlowContext ;
1286+ updateDoctorRun : boolean ;
1287+ writeOptions : {
1288+ allowConfigSizeDrop : boolean ;
1289+ skipPluginValidation : boolean ;
1290+ preservedLegacyRootKeys ?: readonly string [ ] ;
1291+ lastTouchedVersionOverride ?: string ;
1292+ } ;
1293+ } ) : { message : string ; effect : HealthRepairEffect } {
1294+ const changedTopLevelKeys = collectChangedTopLevelConfigKeys (
1295+ params . ctx . cfgForPersistence ,
1296+ params . ctx . cfg ,
1297+ ) ;
1298+ const reasons : string [ ] = [ ] ;
1299+ if ( params . ctx . configResult . shouldWriteConfig === true ) {
1300+ reasons . push ( "doctor config repair requested a write" ) ;
1301+ }
1302+ if ( JSON . stringify ( params . ctx . cfg ) !== JSON . stringify ( params . ctx . cfgForPersistence ) ) {
1303+ reasons . push ( "doctor config changed during health checks" ) ;
1304+ }
1305+ if ( reasons . length === 0 ) {
1306+ reasons . push ( "doctor config write requested" ) ;
1307+ }
1308+
1309+ const lines = [
1310+ `Would write Doctor config changes to ${ params . ctx . configPath } .` ,
1311+ `- reason: ${ reasons . join ( "; " ) } ` ,
1312+ ] ;
1313+ if ( changedTopLevelKeys . length > 0 ) {
1314+ lines . push ( `- changed top-level keys: ${ changedTopLevelKeys . join ( ", " ) } ` ) ;
1315+ }
1316+ lines . push ( "- backup: standard config backup may be written" ) ;
1317+ if ( params . updateDoctorRun ) {
1318+ lines . push ( `- update backup: ${ params . ctx . configPath } .pre-update may be referenced` ) ;
1319+ }
1320+ lines . push (
1321+ `- write options: allowConfigSizeDrop=${ String (
1322+ params . writeOptions . allowConfigSizeDrop ,
1323+ ) } , skipPluginValidation=${ String ( params . writeOptions . skipPluginValidation ) } `,
1324+ ) ;
1325+ if ( ( params . writeOptions . preservedLegacyRootKeys ?. length ?? 0 ) > 0 ) {
1326+ lines . push (
1327+ `- preserved legacy root keys: ${ params . writeOptions . preservedLegacyRootKeys ?. join ( ", " ) } ` ,
1328+ ) ;
1329+ }
1330+ if ( params . writeOptions . lastTouchedVersionOverride ) {
1331+ lines . push ( "- lastTouchedVersion: preserved from source config during update" ) ;
1332+ }
1333+ return {
1334+ message : lines . join ( "\n" ) ,
1335+ effect : {
1336+ kind : "config" ,
1337+ action : "would-write-config" ,
1338+ target : params . ctx . configPath ,
1339+ dryRunSafe : true ,
1340+ } ,
1341+ } ;
1342+ }
1343+
1344+ function collectChangedTopLevelConfigKeys ( before : unknown , after : unknown ) : string [ ] {
1345+ if ( ! isPlainObject ( before ) || ! isPlainObject ( after ) ) {
1346+ return [ ] ;
1347+ }
1348+ const keys = new Set ( [ ...Object . keys ( before ) , ...Object . keys ( after ) ] ) ;
1349+ return [ ...keys ] . filter ( ( key ) => JSON . stringify ( before [ key ] ) !== JSON . stringify ( after [ key ] ) ) ;
1350+ }
1351+
1352+ function isPlainObject ( value : unknown ) : value is Record < string , unknown > {
1353+ return typeof value === "object" && value !== null && ! Array . isArray ( value ) ;
1354+ }
1355+
12721356async function runWorkspaceSuggestionsHealth ( ctx : DoctorHealthFlowContext ) : Promise < void > {
12731357 if ( ctx . options . workspaceSuggestions === false ) {
12741358 return ;
0 commit comments