2026-10-10 17:03:43 +08:00
import { err , ok , type Result } from "../domain/result.js" ;
2026-10-11 06:30:31 +08:00
import {
RegularFileCleanupFailure ,
readRegularFileText ,
} from "./RegularFileRead.js" ;
2026-10-10 17:03:43 +08:00
import {
effectiveClientServer ,
parseClientConfiguration ,
type ClientConfigurationDocument ,
} from "./ClientConfigurationDocument.js" ;
import type { SetupClient } from "./SupportedClients.js" ;
2026-10-10 23:23:02 +01:00
import { clientRegistrationEnvironment } from "./ClientRegistrationEnvironment.js" ;
2026-10-12 05:56:26 +08:00
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js" ;
2026-10-14 08:25:53 +08:00
import type { ConfigurationError } from "../domain/configurationErrors.js" ;
/** A parsed layer retains its path so rereading the write target replaces only that layer. */
export interface ClientConfigurationSource extends ClientConfigurationDocument {
readonly sourcePath : string ;
}
const invalidEnvironment = (
path : string ,
error : ConfigurationError ,
) : ClientConfigurationFileError => ({
kind : "malformed" ,
path ,
detail : `Configuration ${ path } has an invalid REA server environment: ${ error . settings . map (({ setting , constraint } ) => ` $ { setting } : $ { constraint } `).join("; ")}. Repair it before rerunning setup.` ,
});
2026-10-10 17:03:43 +08:00
/** A named source failure that setup can explain without losing the bad path. */
export interface ClientConfigurationFileError {
readonly kind : "unreadable" | "malformed" ;
readonly path : string ;
readonly detail : string ;
2026-10-11 06:30:31 +08:00
readonly cleanupFailure? : RegularFileCleanupFailure ;
2026-10-10 17:03:43 +08:00
}
/** Preserve named read failures and distinguish a missing source from an empty file. */
export const readConfigurationText = async (
path : string ,
2026-10-12 05:56:26 +08:00
resources? : ArtifactResourceScope ,
2026-10-10 17:03:43 +08:00
) : Promise < Result < string | undefined , ClientConfigurationFileError >> => {
try {
2026-10-12 05:56:26 +08:00
return ok (
await readRegularFileText (
path ,
resources === undefined ? {} : { resources },
),
);
2026-10-10 17:03:43 +08:00
} catch ( cause : unknown ) {
if ( cause instanceof Error && "code" in cause && cause . code === "ENOENT" )
return ok ( undefined );
return err ({
kind : "unreadable" ,
path ,
detail : `Configuration ${ path } could not be read: ${ cause instanceof Error ? cause.message : String ( cause ) } ` ,
2026-10-11 06:30:31 +08:00
...( cause instanceof RegularFileCleanupFailure
? { cleanupFailure : cause }
: {}),
2026-10-10 17:03:43 +08:00
});
}
};
/** Read and parse one configuration at its source boundary. */
export const readConfigurationFile = async (
path : string ,
format : SetupClient [ "format" ],
2026-10-12 05:56:26 +08:00
resources? : ArtifactResourceScope ,
2026-10-10 17:03:43 +08:00
) : Promise <
Result < ClientConfigurationDocument | undefined , ClientConfigurationFileError >
> => {
2026-10-12 05:56:26 +08:00
const source = await readConfigurationText ( path , resources );
2026-10-10 17:03:43 +08:00
if ( ! source . ok ) return source ;
const content = source . value ;
if ( content === undefined ) return ok ( undefined );
try {
return ok ( parseClientConfiguration ( content , format ));
} catch ( cause : unknown ) {
return err ({
kind : "malformed" ,
path ,
detail : `Configuration ${ path } is malformed: ${ cause instanceof Error ? cause.message : String ( cause ) } ` ,
});
}
};
/** Read every applicable user file; malformed lower-priority files also stop setup. */
export const readClientConfigurationFiles = async (
client : SetupClient ,
2026-10-12 05:56:26 +08:00
resources? : ArtifactResourceScope ,
2026-10-10 17:03:43 +08:00
) : Promise <
2026-10-14 08:25:53 +08:00
Result < readonly ClientConfigurationSource [] , ClientConfigurationFileError >
2026-10-10 17:03:43 +08:00
> => {
2026-10-14 08:25:53 +08:00
const documents : ClientConfigurationSource [] = [];
2026-10-10 17:03:43 +08:00
for ( const path of client . configPaths ?? [ client . configPath ]) {
2026-10-12 05:56:26 +08:00
const document = await readConfigurationFile (
path ,
client . format ,
resources ,
);
2026-10-10 17:03:43 +08:00
if ( ! document . ok ) return document ;
2026-10-10 23:23:02 +01:00
if ( document . value !== undefined ) {
2026-10-14 08:25:53 +08:00
const environment = clientRegistrationEnvironment (
document . value ,
{},
"fragment" ,
);
2026-10-10 23:23:02 +01:00
if ( ! environment . ok )
2026-10-14 08:25:53 +08:00
return err ( invalidEnvironment ( path , environment . error ));
documents . push ({ ... document . value , sourcePath : path });
2026-10-10 23:23:02 +01:00
}
2026-10-10 17:03:43 +08:00
}
2026-10-14 08:25:53 +08:00
const environment = clientRegistrationEnvironment (
effectiveClientConfiguration ( documents ),
);
if ( ! environment . ok )
return err ( invalidEnvironment ( client . configPath , environment . error ));
2026-10-10 17:03:43 +08:00
return ok ( documents );
};
/** OpenCode merges server objects in file order, replacing arrays and scalar values. */
const mergeConfigurationValue = ( previous : unknown , next : unknown ) : unknown => {
if ( ! isObject ( previous ) || ! isObject ( next )) return next ;
return Object . fromEntries (
[... new Set ([... Object . keys ( previous ), ... Object . keys ( next )])]. map (
( key ) => [
key ,
Object . hasOwn ( next , key )
? mergeConfigurationValue ( previous [ key ], next [ key ])
: previous [ key ],
],
),
);
};
const isObject = ( value : unknown ) : value is Record < string , unknown > =>
typeof value === "object" && value !== null && ! Array . isArray ( value );
/** Project a client's effective user registration without modifying its source files. */
export const effectiveClientConfiguration = (
2026-10-14 08:25:53 +08:00
sources : readonly ClientConfigurationSource [],
target ?: {
readonly client : Pick < SetupClient , "configPath" | "configPaths" >;
readonly document : ClientConfigurationDocument | undefined ;
},
2026-10-10 17:03:43 +08:00
) : ClientConfigurationDocument | undefined => {
2026-10-14 08:25:53 +08:00
const documents =
target === undefined
? sources
: ( target . client . configPaths ?? [ target . client . configPath ]). flatMap (
( path ) => {
const document =
path === target . client . configPath
? target.document
: sources.find (({ sourcePath }) => sourcePath === path );
return document === undefined ? [] : [ document ];
},
);
2026-10-10 17:03:43 +08:00
const last = documents . at ( - 1 );
if ( last === undefined ) return undefined ;
if ( documents . length === 1 ) return last ;
const servers : Record < string , unknown > = {};
for ( const document of documents ) {
const names = new Set ([
... Object . keys ( document . legacyServers ),
... Object . keys ( document . servers ),
]);
for ( const name of names )
servers [ name ] = mergeConfigurationValue (
servers [ name ],
effectiveClientServer ( document , name ),
);
}
return { ... last , servers , legacyServers : {} };
};
2026-10-14 13:58:15 +08:00
/** Read the configuration ZCode will load, preserving its same-scope fallback rule. */
export const readEffectiveClientRegistrationConfiguration = async (
client : SetupClient ,
resources? : ArtifactResourceScope ,
) : Promise <
Result <
{
readonly document : ClientConfigurationDocument | undefined ;
readonly configPath : string ;
},
ClientConfigurationFileError
>
> => {
const nativeSources = await readClientConfigurationFiles ( client , resources );
if ( ! nativeSources . ok ) return nativeSources ;
const nativeDocument = effectiveClientConfiguration ( nativeSources . value );
const fallbackPath =
client . format === "zcode" ? client.zcodeFallbackPath : undefined ;
if (
fallbackPath === undefined ||
( nativeDocument !== undefined &&
Object . keys ( nativeDocument . servers ). length > 0 )
)
return ok ({ document : nativeDocument , configPath : client.configPath });
const fallbackClient : SetupClient = {
... client ,
configPath : fallbackPath ,
configPaths : [ fallbackPath ],
format : "zcode_fallback" ,
};
const fallbackSources = await readClientConfigurationFiles (
fallbackClient ,
resources ,
);
if ( ! fallbackSources . ok ) return fallbackSources ;
const fallbackDocument = effectiveClientConfiguration ( fallbackSources . value );
return ok (
fallbackDocument === undefined
? { document : nativeDocument , configPath : client.configPath }
: { document : fallbackDocument , configPath : fallbackPath },
);
};