UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

635 lines 26.5 kB
import path from 'path'; import { isString } from 'es-toolkit/compat'; import { cloneDeep as clone, merge, omit } from 'es-toolkit/object'; import { isEqual, isPlainObject } from 'es-toolkit/predicate'; import { readJsonFile, writeJsonFile } from '../../../utils/file.js'; import { normalizeOptionalString } from '../../../utils/value.js'; import { resolveAtlasWorkloadFile } from '../../../utils/atlas.js'; import { getSearchProvider } from '../providers/index.js'; import { resolveProjectScopedSearchSourceConfig } from './sources.js'; import { getProviderSectionEntries, validateSearchProjectSection, validateResolvedSearchConfig, validateSearchWorkloadConfig, validateSearchRootSection } from './searchValidation.js'; const SEARCH_WORKLOAD_NAME = 'search'; const SEARCH_DIRECTORY_PREFIX = 'search/'; const DEFAULT_SEARCH_PROVIDER = 'typesense'; const SEARCH_CONFIG_FILE_NAME = 'config.json'; const SEARCH_WORKLOAD_DIRECTORY_PREFIX = 'services/search/'; export const DEFAULT_SEARCH_WORKLOAD_PATH = 'workloads/users.json'; export const DEFAULT_SEARCH_TERRAFORM_ROOT_DIR = 'services/search/terraform'; export const DEFAULT_SEARCH_WORKLOAD_TERRAFORM_ROOT_DIR = DEFAULT_SEARCH_TERRAFORM_ROOT_DIR; export const DEFAULT_SEARCH_TERRAFORM_MODULE_RELEASE_TAG = 'v0.1.0'; export const DEFAULT_SEARCH_TERRAFORM_MODULE_SOURCE = `git::https://github.com/limebooth/atlas-terraform-modules.git//search/backfill_job?ref=${DEFAULT_SEARCH_TERRAFORM_MODULE_RELEASE_TAG}`; export const DEFAULT_SEARCH_SYNC_FAILURE_COLLECTION_PATH = 'sync/search/sync_state'; export const DEFAULT_SEARCH_SYNC_FAILURE_RECENT_LIMIT = 5; export const DEFAULT_ATLAS_SEARCH_WORKLOAD_CONTENT = { source: { enabled: false, firestore: { documentPathPattern: 'users/*' }, name: 'users_firestore', routeTemplate: '/users/:id', syncClass: 'delta-merge', type: 'firestore' }, index: { fields: [{ name: 'id', type: 'string' }, { name: 'entityType', type: 'string' }, { name: 'title', type: 'string' }, { name: 'content', type: 'string' }, { name: 'displayName', type: 'string' }, { name: 'email', type: 'string' }, { name: 'fname', type: 'string' }, { name: 'lname', type: 'string' }, { facet: true, name: 'role', optional: true, type: 'string[]' }, { facet: true, filter: true, name: 'status', optional: true, type: 'int32' }, { name: 'phoneNumber', optional: true, type: 'string' }, { name: 'photoURL', optional: true, type: 'string' }, { name: 'sourcePath', type: 'string' }, { group: true, name: 'organizationId', optional: true, type: 'string' }, { name: 'updatedAt', optional: true, sort: true, type: 'int64' }], defaultSort: [{ direction: 'desc', field: 'updatedAt' }], facets: ['role', 'status'], filterBy: ['status'], groupBy: ['organizationId'], name: 'users_search', queryBy: ['displayName', 'email', 'fname', 'lname'] }, mapper: { export: 'mapUserToSearchDocument', name: 'user', source: 'mappers/user.ts' } }; const getSearchProjectEntries = searchConfig => isPlainObject(searchConfig?.projects) ? Object.entries(searchConfig.projects) : []; export const hasSearchProjectConfigs = searchConfig => getSearchProjectEntries(searchConfig).length > 0; export const createDefaultSearchCloudRunServiceAccountEmail = projectId => { const normalizedProjectId = normalizeOptionalString(projectId); if (!normalizedProjectId) { return null; } return `${normalizedProjectId}@appspot.gserviceaccount.com`; }; export const resolveSearchCloudRunServiceAccountEmail = (searchConfig, projectId) => { const configuredValue = searchConfig?.deploy?.cloudRun?.serviceAccountEmail; return normalizeOptionalString(configuredValue) ?? createDefaultSearchCloudRunServiceAccountEmail(projectId); }; const normalizePathSeparators = filePath => filePath.replaceAll(path.sep, '/'); export const resolveSearchConfigLocation = (cwd = process.cwd(), dependencies = {}) => { const resolution = resolveAtlasWorkloadFile(SEARCH_WORKLOAD_NAME, SEARCH_CONFIG_FILE_NAME, cwd, dependencies); return { ...resolution, configDirectory: path.dirname(resolution.activePath), configPath: resolution.activePath }; }; const resolveSearchScopedPath = (targetPath, cwd, baseDirectory) => { const normalizedTargetPath = normalizePathSeparators(targetPath); if (baseDirectory !== cwd && (normalizedTargetPath.startsWith(SEARCH_DIRECTORY_PREFIX) || normalizedTargetPath.startsWith(SEARCH_WORKLOAD_DIRECTORY_PREFIX))) { return path.resolve(cwd, normalizedTargetPath); } return path.resolve(baseDirectory, normalizedTargetPath); }; const createDefaultSearchRootConfig = (providerName = DEFAULT_SEARCH_PROVIDER, options = {}) => { const { terraformRootDir = DEFAULT_SEARCH_TERRAFORM_ROOT_DIR } = options; const provider = getSearchProvider(providerName ?? DEFAULT_SEARCH_PROVIDER); const rootConfig = { provider: provider.name, workloads: [DEFAULT_SEARCH_WORKLOAD_PATH], deploy: { cloudRun: { artifactRegistryLocation: 'europe-west1', artifactRegistryProject: 'puls-atlas-core', mapperManifestUri: null, repository: 'atlas-runtime-containers', region: 'europe-west1', runtimeConfigUri: null, searchConfigTransport: 'artifact' }, syncState: { collectionPath: DEFAULT_SEARCH_SYNC_FAILURE_COLLECTION_PATH, recentLimit: DEFAULT_SEARCH_SYNC_FAILURE_RECENT_LIMIT }, terraform: { moduleSource: DEFAULT_SEARCH_TERRAFORM_MODULE_SOURCE, rootDir: terraformRootDir } }, release: { active: null, candidate: null, strategy: 'direct' } }; if (provider.configSectionKey && provider.createDefaultConfigSection) { rootConfig[provider.configSectionKey] = provider.createDefaultConfigSection(); } return rootConfig; }; const createDefaultSearchSharedRootConfig = (providerName = DEFAULT_SEARCH_PROVIDER) => ({ workloads: [DEFAULT_SEARCH_WORKLOAD_PATH], provider: getSearchProvider(providerName ?? DEFAULT_SEARCH_PROVIDER).name }); const createDefaultSearchProjectConfig = (providerName = DEFAULT_SEARCH_PROVIDER, options = {}) => { const defaultSearchConfig = createDefaultSearchRootConfig(providerName, options); const defaultProjectConfig = { deploy: clone(defaultSearchConfig.deploy ?? {}), release: clone(defaultSearchConfig.release ?? {}) }; for (const [sectionKey] of getProviderSectionEntries()) { if (defaultSearchConfig[sectionKey] === undefined) { continue; } defaultProjectConfig[sectionKey] = clone(defaultSearchConfig[sectionKey]); } return defaultProjectConfig; }; const createCompleteSearchProjectConfig = (projectConfig, providerName, options = {}) => { const defaultProjectConfig = createDefaultSearchProjectConfig(providerName, options); const mergedProjectConfig = {}; const configuredEnvironment = normalizeOptionalString(projectConfig?.environment); if (configuredEnvironment) { mergedProjectConfig.environment = configuredEnvironment; } mergedProjectConfig.deploy = merge(clone(defaultProjectConfig.deploy ?? {}), projectConfig?.deploy ?? {}); mergedProjectConfig.release = merge(clone(defaultProjectConfig.release ?? {}), projectConfig?.release ?? {}); for (const [sectionKey] of getProviderSectionEntries()) { const defaultSection = defaultProjectConfig[sectionKey]; const projectSection = projectConfig?.[sectionKey]; if (defaultSection === undefined && projectSection === undefined) { continue; } mergedProjectConfig[sectionKey] = merge(clone(defaultSection ?? {}), projectSection ?? {}); } if (projectConfig?.sourceBindings !== undefined) { mergedProjectConfig.sourceBindings = clone(projectConfig.sourceBindings); } return validateSearchProjectSection(mergedProjectConfig, options.projectId); }; const createDefaultProjectScopedSearchRootConfig = (providerName = DEFAULT_SEARCH_PROVIDER, options = {}) => ({ ...createDefaultSearchSharedRootConfig(providerName), projects: { [options.projectId]: createDefaultSearchProjectConfig(providerName, options) } }); export const createDefaultSearchSection = (providerName, options = {}) => clone(createDefaultSearchRootConfig(providerName, options)); const createCompleteSearchRootConfig = (searchConfig, options = {}) => { const defaultSearchConfig = hasSearchProjectConfigs(searchConfig) ? createDefaultSearchSharedRootConfig(searchConfig?.provider) : createDefaultSearchSection(searchConfig?.provider, options); const providerSectionEntries = getProviderSectionEntries(); const providerSectionKeys = new Set(providerSectionEntries.map(([k]) => k)); const mergedConfig = clone(defaultSearchConfig); for (const [key, value] of Object.entries(searchConfig ?? {})) { if (key !== 'deploy' && key !== 'projects' && key !== 'release' && !providerSectionKeys.has(key)) { mergedConfig[key] = clone(value); } } if (defaultSearchConfig.deploy !== undefined || searchConfig?.deploy !== undefined) { mergedConfig.deploy = merge(clone(defaultSearchConfig.deploy ?? {}), searchConfig?.deploy ?? {}); } if (defaultSearchConfig.release !== undefined || searchConfig?.release !== undefined) { mergedConfig.release = merge(clone(defaultSearchConfig.release ?? {}), searchConfig?.release ?? {}); } for (const [sectionKey] of providerSectionEntries) { if (defaultSearchConfig[sectionKey] === undefined && searchConfig?.[sectionKey] === undefined) { continue; } mergedConfig[sectionKey] = merge(clone(defaultSearchConfig[sectionKey] ?? {}), searchConfig?.[sectionKey] ?? {}); } if (searchConfig?.projects !== undefined || hasSearchProjectConfigs(searchConfig)) { mergedConfig.projects = {}; for (const [projectId, projectConfig] of getSearchProjectEntries(searchConfig)) { mergedConfig.projects[projectId] = createCompleteSearchProjectConfig(projectConfig, mergedConfig.provider, { ...options, projectId }); } } return validateSearchRootSection(mergedConfig); }; export const resolveSearchProjectConfig = (searchConfig, projectId, options = {}) => { const completeRootConfig = createCompleteSearchRootConfig(searchConfig, options); if (!hasSearchProjectConfigs(completeRootConfig)) { return { config: completeRootConfig, projectConfig: null, rootConfig: completeRootConfig }; } const normalizedProjectId = normalizeOptionalString(projectId); if (!normalizedProjectId) { throw new Error('Atlas search config defines project-specific settings under "projects". Re-run with --project or --environment to select a project.'); } const resolvedProjectConfig = completeRootConfig.projects?.[normalizedProjectId] ?? null; if (!resolvedProjectConfig) { throw new Error(`Atlas search config does not define a "projects.${normalizedProjectId}" entry.`); } const configuredEnvironment = normalizeOptionalString(resolvedProjectConfig.environment); const selectedEnvironment = normalizeOptionalString(options.environment); if (configuredEnvironment !== null && selectedEnvironment !== null && configuredEnvironment !== selectedEnvironment) { throw new Error(`Atlas search config project "${normalizedProjectId}" declares environment "${configuredEnvironment}", but the selected project resolves to "${selectedEnvironment}" via .firebaserc.`); } const resolvedConfig = clone(omit(completeRootConfig, ['projects'])); resolvedConfig.deploy = merge(clone(completeRootConfig.deploy ?? {}), resolvedProjectConfig.deploy ?? {}); resolvedConfig.release = merge(clone(completeRootConfig.release ?? {}), resolvedProjectConfig.release ?? {}); for (const [sectionKey] of getProviderSectionEntries()) { const rootSection = completeRootConfig[sectionKey]; const projectSection = resolvedProjectConfig[sectionKey]; if (rootSection === undefined && projectSection === undefined) { continue; } resolvedConfig[sectionKey] = merge(clone(rootSection ?? {}), projectSection ?? {}); } return { config: resolvedConfig, projectConfig: resolvedProjectConfig, rootConfig: completeRootConfig }; }; export const applySearchProjectConfigPatch = (searchConfig, projectId, patch, options = {}) => { const completeRootConfig = createCompleteSearchRootConfig(searchConfig, options); const requiresProjectPatch = hasSearchProjectConfigs(completeRootConfig) || patch.environment !== undefined || patch.sourceBindings !== undefined; if (!requiresProjectPatch) { const nextRootConfig = clone(completeRootConfig); if (patch.deploy !== undefined) { nextRootConfig.deploy = merge(clone(nextRootConfig.deploy ?? {}), patch.deploy ?? {}); } if (patch.release !== undefined) { nextRootConfig.release = merge(clone(nextRootConfig.release ?? {}), patch.release ?? {}); } for (const [sectionKey] of getProviderSectionEntries()) { if (patch[sectionKey] === undefined) { continue; } nextRootConfig[sectionKey] = merge(clone(nextRootConfig[sectionKey] ?? {}), patch[sectionKey] ?? {}); } return validateSearchRootSection(nextRootConfig); } const normalizedProjectId = normalizeOptionalString(projectId); if (!normalizedProjectId) { throw new Error('Atlas search config defines project-specific settings under "projects". Re-run with --project or --environment to select a project.'); } const currentProjectConfig = completeRootConfig.projects?.[normalizedProjectId] ?? {}; const nextProjectConfig = clone(currentProjectConfig); if (patch.environment !== undefined) { const normalizedEnvironment = normalizeOptionalString(patch.environment); if (normalizedEnvironment) { nextProjectConfig.environment = normalizedEnvironment; } else { delete nextProjectConfig.environment; } } if (patch.deploy !== undefined) { nextProjectConfig.deploy = merge(clone(currentProjectConfig.deploy ?? {}), patch.deploy ?? {}); } if (patch.release !== undefined) { nextProjectConfig.release = merge(clone(currentProjectConfig.release ?? {}), patch.release ?? {}); } if (patch.sourceBindings !== undefined) { nextProjectConfig.sourceBindings = merge(clone(currentProjectConfig.sourceBindings ?? {}), patch.sourceBindings ?? {}); } for (const [sectionKey] of getProviderSectionEntries()) { if (patch[sectionKey] === undefined) { continue; } nextProjectConfig[sectionKey] = merge(clone(currentProjectConfig[sectionKey] ?? {}), patch[sectionKey] ?? {}); } return validateSearchRootSection({ ...completeRootConfig, projects: { ...(completeRootConfig.projects ?? {}), [normalizedProjectId]: createCompleteSearchProjectConfig(nextProjectConfig, completeRootConfig.provider, { ...options, projectId: normalizedProjectId }) } }); }; const normalizeSearchWorkloadConfig = (config, cwd, sourceBaseDirectory) => { const normalizedConfig = clone(config); if (isString(normalizedConfig.mapper?.source)) { const resolvedSourcePath = resolveSearchScopedPath(normalizedConfig.mapper.source, cwd, sourceBaseDirectory); normalizedConfig.mapper.source = normalizePathSeparators(path.relative(cwd, resolvedSourcePath)); } return normalizedConfig; }; const createSearchCollectionFromFirestoreWorkload = (sourceConfig, mapperConfig, indexConfig) => { const collectionConfig = { enabled: sourceConfig?.enabled !== false, indexes: [indexConfig.name], mapper: mapperConfig.name, sourcePathPattern: sourceConfig.firestore.documentPathPattern }; const routeTemplate = normalizeOptionalString(sourceConfig?.routeTemplate); if (routeTemplate) { collectionConfig.routeTemplate = routeTemplate; } return collectionConfig; }; const createSearchSourceFromFirestoreWorkload = (sourceConfig, mapperConfig, indexConfig) => { const sourceName = sourceConfig.name; const firestoreConfig = sourceConfig.firestore ?? {}; const resolvedSourceConfig = { collection: sourceName, enabled: sourceConfig?.enabled !== false, eventarc: { documentPathPattern: firestoreConfig.documentPathPattern }, indexes: [indexConfig.name], mapper: mapperConfig.name, syncClass: sourceConfig.syncClass, type: 'firestore' }; const description = normalizeOptionalString(sourceConfig?.description); const routeTemplate = normalizeOptionalString(sourceConfig?.routeTemplate); if (description) { resolvedSourceConfig.description = description; } if (routeTemplate) { resolvedSourceConfig.routeTemplate = routeTemplate; } if (normalizeOptionalString(firestoreConfig.database)) { resolvedSourceConfig.eventarc.database = firestoreConfig.database; } if (normalizeOptionalString(firestoreConfig.triggerRegion)) { resolvedSourceConfig.eventarc.triggerRegion = firestoreConfig.triggerRegion; } return resolvedSourceConfig; }; const createSearchScheduledSourceConfigFromWorkload = (workloadConfig, sourceConfig) => ({ ...clone(omit(sourceConfig, ['enabled', 'firestore', 'name'])), indexes: [workloadConfig.index.name], mapper: workloadConfig.mapper.name }); const resolveProjectScopedSearchSources = (scheduledSourceConfigs, sourceBindings, projectId = null) => { const resolvedSources = {}; for (const [sourceName, sourceBinding] of Object.entries(sourceBindings ?? {})) { const scheduledSourceConfig = scheduledSourceConfigs?.[sourceName] ?? null; if (!scheduledSourceConfig) { throw new Error(`Atlas search project${projectId ? ` "${projectId}"` : ''} defines a scheduled source binding for ` + `"${sourceName}" but no matching workload-defined source exists.`); } resolvedSources[sourceName] = resolveProjectScopedSearchSourceConfig(sourceName, scheduledSourceConfig, sourceBinding); } return resolvedSources; }; const mergeSearchRegistry = (target, source, registryName, sourceLabel) => { for (const [entryName, entryConfig] of Object.entries(source ?? {})) { if (Object.hasOwn(target, entryName)) { throw new Error(`Duplicate Atlas search ${registryName} "${entryName}" found while loading ${sourceLabel}.`); } target[entryName] = clone(entryConfig); } }; const resolveSearchConfig = (searchConfig, version, cwd = process.cwd(), options = {}) => { const { configBaseDirectory = cwd, projectId = null, sourceBaseDirectory = cwd } = options; const defaultTerraformRootDir = options.defaultTerraformRootDir ?? DEFAULT_SEARCH_TERRAFORM_ROOT_DIR; const { config: validatedSearchConfig, projectConfig } = resolveSearchProjectConfig(searchConfig, projectId, { environment: options.environment ?? null, defaultTerraformRootDir, terraformRootDir: defaultTerraformRootDir }); const resolvedConfig = { collections: {}, deploy: clone(validatedSearchConfig.deploy ?? {}), indexes: {}, mappers: {}, provider: validatedSearchConfig.provider, release: clone(validatedSearchConfig.release ?? {}), sources: {}, version }; const resolvedScheduledSourceConfigs = {}; for (const [sectionKey] of getProviderSectionEntries()) { resolvedConfig[sectionKey] = clone(validatedSearchConfig[sectionKey] ?? {}); } const resolvedConfigPaths = []; for (const workloadRelativePath of validatedSearchConfig.workloads ?? []) { const workloadPath = resolveSearchScopedPath(workloadRelativePath, cwd, configBaseDirectory); const workloadConfig = normalizeSearchWorkloadConfig(validateSearchWorkloadConfig(readJsonFile(workloadPath), workloadPath), cwd, sourceBaseDirectory); const sourceName = workloadConfig.source.name; const mapperName = workloadConfig.mapper.name; const indexName = workloadConfig.index.name; mergeSearchRegistry(resolvedConfig.mappers, { [mapperName]: omit(workloadConfig.mapper, ['name']) }, 'mapper', workloadPath); mergeSearchRegistry(resolvedConfig.indexes, { [indexName]: omit(workloadConfig.index, ['name']) }, 'index', workloadPath); if (workloadConfig.source.type.trim().toLowerCase() === 'firestore') { mergeSearchRegistry(resolvedConfig.collections, { [sourceName]: createSearchCollectionFromFirestoreWorkload(workloadConfig.source, workloadConfig.mapper, workloadConfig.index) }, 'collection', workloadPath); mergeSearchRegistry(resolvedConfig.sources, { [sourceName]: createSearchSourceFromFirestoreWorkload(workloadConfig.source, workloadConfig.mapper, workloadConfig.index) }, 'source', workloadPath); } else { mergeSearchRegistry(resolvedScheduledSourceConfigs, { [sourceName]: createSearchScheduledSourceConfigFromWorkload(workloadConfig, workloadConfig.source) }, 'scheduled source config', workloadPath); } resolvedConfigPaths.push(workloadPath); } mergeSearchRegistry(resolvedConfig.sources, resolveProjectScopedSearchSources(resolvedScheduledSourceConfigs, projectConfig?.sourceBindings ?? {}, projectId), 'source', `project${projectId ? ` "${projectId}"` : ''} source bindings`); return { config: validateResolvedSearchConfig(resolvedConfig), configPaths: resolvedConfigPaths, projectConfig }; }; export const loadSearchConfig = (cwd = process.cwd(), options = {}) => { const version = 1; const configLocation = resolveSearchConfigLocation(cwd); const searchConfig = readJsonFile(configLocation.configPath, { allowMissing: true }); const { configPath, configDirectory, preferredPath } = configLocation; if (searchConfig) { const { config, configPaths, projectConfig } = resolveSearchConfig(searchConfig, version, cwd, { configBaseDirectory: configDirectory, defaultTerraformRootDir: DEFAULT_SEARCH_WORKLOAD_TERRAFORM_ROOT_DIR, environment: options.environment ?? null, projectId: options.projectId ?? null, sourceBaseDirectory: configDirectory }); return { config, configPath, configPaths, projectConfig, rootConfig: searchConfig, warnings: [] }; } throw new Error(`Atlas search config is missing. Expected ${preferredPath}.`); }; export const ensureSearchConfigSection = (cwd = process.cwd(), options = {}) => { const createdFiles = []; const updatedFiles = []; const configLocation = resolveSearchConfigLocation(cwd); const { configPath } = configLocation; const existingSearchConfig = readJsonFile(configPath, { allowMissing: true }); const didSearchConfigExist = Boolean(existingSearchConfig); const environment = normalizeOptionalString(options.environment); const providerName = normalizeOptionalString(options.providerName)?.toLowerCase(); const projectId = normalizeOptionalString(options.projectId); const defaultTerraformRootDir = DEFAULT_SEARCH_WORKLOAD_TERRAFORM_ROOT_DIR; let nextSearchConfigInput = existingSearchConfig; if (!nextSearchConfigInput) { nextSearchConfigInput = projectId ? createDefaultProjectScopedSearchRootConfig(providerName, { environment, projectId, terraformRootDir: defaultTerraformRootDir }) : createDefaultSearchSection(providerName, { terraformRootDir: defaultTerraformRootDir }); } else if (!hasSearchProjectConfigs(nextSearchConfigInput) && projectId) { nextSearchConfigInput = { ...clone(nextSearchConfigInput), projects: { [projectId]: {} } }; } else if (hasSearchProjectConfigs(nextSearchConfigInput) && projectId) { nextSearchConfigInput = { ...clone(nextSearchConfigInput), projects: { ...(nextSearchConfigInput.projects ?? {}), [projectId]: { ...(nextSearchConfigInput.projects?.[projectId] ?? {}) } } }; } else if (hasSearchProjectConfigs(nextSearchConfigInput) && !projectId) { throw new Error('Atlas search config defines project-specific settings under "projects". Re-run with --project or --environment to select a project.'); } const nextSearchConfig = createCompleteSearchRootConfig(nextSearchConfigInput, { terraformRootDir: defaultTerraformRootDir }); writeJsonFile(configPath, nextSearchConfig); if (!didSearchConfigExist) { createdFiles.push(configPath); } else if (!isEqual(existingSearchConfig, nextSearchConfig)) { updatedFiles.push(configPath); } const defaultWorkloadPath = path.join(configLocation.configDirectory, DEFAULT_SEARCH_WORKLOAD_PATH); if (!readJsonFile(defaultWorkloadPath, { allowMissing: true })) { writeJsonFile(defaultWorkloadPath, DEFAULT_ATLAS_SEARCH_WORKLOAD_CONTENT); createdFiles.push(defaultWorkloadPath); } let resolvedSearchConfig = nextSearchConfig; let resolvedProjectConfig = null; if (hasSearchProjectConfigs(nextSearchConfig)) { const resolvedProject = resolveSearchProjectConfig(nextSearchConfig, projectId, { defaultTerraformRootDir, projectId, terraformRootDir: defaultTerraformRootDir }); resolvedSearchConfig = resolvedProject.config; resolvedProjectConfig = resolvedProject.projectConfig; } return { config: resolvedSearchConfig, configPath, createdFiles, projectConfig: resolvedProjectConfig, rootConfig: nextSearchConfig, updatedFiles }; }; export const writeSearchRuntimeServicesToProjectConfig = (configPath, projectId, serviceUrls) => { const existing = readJsonFile(configPath, { allowMissing: true }); if (!existing || !projectId) { return false; } const existingProject = existing.projects?.[projectId] ?? {}; const existingServices = existingProject.runtime?.services ?? {}; if (existingServices.apiServiceUrl === serviceUrls.apiServiceUrl && existingServices.syncServiceUrl === serviceUrls.syncServiceUrl) { return false; } const next = { ...existing, projects: { ...(existing.projects ?? {}), [projectId]: { ...existingProject, runtime: { ...(existingProject.runtime ?? {}), services: { apiServiceUrl: serviceUrls.apiServiceUrl, syncServiceUrl: serviceUrls.syncServiceUrl } } } } }; writeJsonFile(configPath, next); return true; };