From 470b0c07fb1aee7553906bf8e1b95aebb17355f3 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Mon, 15 Jun 2026 23:30:47 +0500 Subject: [PATCH 01/10] sync local env files with cloud on pull/push --- src/cloud/firestoreClient.ts | 222 ++++++++++++++++++ src/commands/pull.ts | 15 ++ src/commands/push.ts | 120 +++++++++- src/commands/release.ts | 9 + src/core/envConfig.ts | 62 ++++- src/core/envSync.ts | 309 +++++++++++++++++++++++++ src/core/pullAssets.ts | 5 +- src/core/sync.ts | 31 ++- tests/cloud/firestoreClient.test.ts | 163 ++++++++++++++ tests/commands/pushPull.test.ts | 88 ++++++++ tests/commands/release.test.ts | 133 +++++++++++ tests/core/envSync.test.ts | 335 ++++++++++++++++++++++++++++ 12 files changed, 1472 insertions(+), 20 deletions(-) create mode 100644 src/core/envSync.ts create mode 100644 tests/core/envSync.test.ts diff --git a/src/cloud/firestoreClient.ts b/src/cloud/firestoreClient.ts index db35f9c..ff2e87c 100644 --- a/src/cloud/firestoreClient.ts +++ b/src/cloud/firestoreClient.ts @@ -12,6 +12,8 @@ import type { ScreenDTO, ThemeDTO, TranslationDTO, + ConfigDTO, + SecretDTO, } from '../core/dto.js'; import { EnsembleDocumentType } from '../core/dto.js'; import { getArtifactConfig, type ArtifactProp } from '../core/artifacts.js'; @@ -195,6 +197,8 @@ export type CloudApp = Pick< | 'theme' | 'translations' | 'assets' + | 'config' + | 'secrets' >; /** Metadata for a saved version (commit); snapshot stored in same doc. */ @@ -773,6 +777,218 @@ function toAssetDTO(doc: FirestoreDocument): AssetDTO { }; } +function parseFirestoreMapField( + field: { mapValue?: { fields?: Record } } | undefined +): Record | undefined { + const mapFields = field?.mapValue?.fields; + if (!mapFields || typeof mapFields !== 'object') return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(mapFields)) { + if (typeof (value as { stringValue?: string }).stringValue === 'string') { + result[key] = (value as { stringValue: string }).stringValue; + continue; + } + if (typeof (value as { booleanValue?: boolean }).booleanValue === 'boolean') { + result[key] = (value as { booleanValue: boolean }).booleanValue; + continue; + } + if ((value as { integerValue?: string }).integerValue !== undefined) { + result[key] = Number((value as { integerValue: string }).integerValue); + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function encodeFirestoreStringMap(values: Record): { + mapValue: { fields: Record }; +} { + const fields: Record = {}; + for (const [key, value] of Object.entries(values)) { + fields[key] = { stringValue: value }; + } + return { mapValue: { fields } }; +} + +function configDtoToStringMap(config: ConfigDTO): Record { + const envVariables = config.envVariables ?? {}; + const result: Record = {}; + for (const [key, value] of Object.entries(envVariables)) { + if (value !== undefined && value !== null) { + result[key] = String(value); + } + } + return result; +} + +function secretsDtoToStringMap(secrets: SecretDTO): Record { + const nested = + secrets.secrets && typeof secrets.secrets === 'object' + ? (secrets.secrets as Record) + : (secrets as Record); + const result: Record = {}; + for (const [key, value] of Object.entries(nested)) { + if (key === 'secrets' || value === undefined || value === null) continue; + result[key] = String(value); + } + return result; +} + +function parseJsonObjectField(content: string | undefined): T | undefined { + if (!content) return undefined; + try { + const parsed = JSON.parse(content) as T; + return parsed && typeof parsed === 'object' ? parsed : undefined; + } catch { + return undefined; + } +} + +function toConfigDTO(doc: FirestoreDocument): ConfigDTO | undefined { + const fields = (doc.fields ?? {}) as FirestoreFields; + const fromContent = parseJsonObjectField( + parseFirestoreString(fields.content as { stringValue?: string }) + ); + const envVariablesFromMap = parseFirestoreMapField( + fields.envVariables as { mapValue?: { fields?: Record } } + ); + const baseUrl = parseFirestoreString(fields.baseUrl as { stringValue?: string }); + const useBrowserUrl = parseFirestoreBoolean(fields.useBrowserUrl as { booleanValue?: boolean }); + const envVariables = + envVariablesFromMap ?? (fromContent?.envVariables as Record | undefined); + if (!envVariables && baseUrl === undefined && useBrowserUrl === undefined) { + return undefined; + } + return { + ...(envVariables && { envVariables }), + ...(baseUrl !== undefined && { baseUrl }), + ...(useBrowserUrl !== undefined && { useBrowserUrl }), + }; +} + +function toSecretDTO(doc: FirestoreDocument): SecretDTO | undefined { + const fields = (doc.fields ?? {}) as FirestoreFields; + const fromContent = parseJsonObjectField( + parseFirestoreString(fields.content as { stringValue?: string }) + ); + const secretsFromMap = parseFirestoreMapField( + fields.secrets as { mapValue?: { fields?: Record } } + ); + if (secretsFromMap) { + return { secrets: secretsFromMap as Record }; + } + if (fromContent) return fromContent; + + const flat = parseFirestoreMapField( + fields as { mapValue?: { fields?: Record } } + ); + return flat ? (flat as SecretDTO) : undefined; +} + +async function upsertEnvArtifactDocument( + appId: string, + idToken: string, + project: string, + documentId: string, + typeValue: string, + contentJson: string, + mapFieldName: 'envVariables' | 'secrets', + mapValues: Record, + options?: FirestoreClientOptions +): Promise { + const collectionUrl = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}/artifacts`; + const docUrl = `${collectionUrl}/${encodeURIComponent(documentId)}`; + const updatedAt = new Date().toISOString(); + const patchFields: FirestoreWriteFields = { + content: { stringValue: contentJson }, + [mapFieldName]: encodeFirestoreStringMap(mapValues), + updatedAt: { timestampValue: updatedAt }, + }; + const fieldPaths = ['content', mapFieldName, 'updatedAt']; + + logDebug(options, { + kind: 'request', + method: 'PATCH', + url: `${docUrl}?${fieldPaths.map((p) => `updateMask.fieldPaths=${encodeURIComponent(p)}`).join('&')}`, + context: 'submitEnvDocumentsPush/patch', + }); + const patchRes = await fetch( + `${docUrl}?${fieldPaths.map((p) => `updateMask.fieldPaths=${encodeURIComponent(p)}`).join('&')}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields: patchFields }), + } + ); + + if (patchRes.ok) return; + + if (patchRes.status !== 404) { + throw await toFirestoreError(`update ${documentId}`, patchRes, options); + } + + const createUrl = `${collectionUrl}?documentId=${encodeURIComponent(documentId)}`; + const createFields: FirestoreWriteFields = { + name: { stringValue: documentId }, + type: { stringValue: typeValue }, + ...patchFields, + }; + logDebug(options, { + kind: 'request', + method: 'POST', + url: createUrl, + context: 'submitEnvDocumentsPush/create', + }); + const createRes = await fetch(createUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields: createFields }), + }); + if (!createRes.ok) { + throw await toFirestoreError(`create ${documentId}`, createRes, options); + } +} + +export async function submitEnvDocumentsPush( + appId: string, + idToken: string, + payload: { config?: ConfigDTO; secrets?: SecretDTO }, + options?: FirestoreClientOptions +): Promise { + const project = getEnsembleFirebaseProject(); + if (payload.config) { + await upsertEnvArtifactDocument( + appId, + idToken, + project, + 'appConfig', + EnsembleDocumentType.Environment, + JSON.stringify(payload.config), + 'envVariables', + configDtoToStringMap(payload.config), + options + ); + } + if (payload.secrets) { + await upsertEnvArtifactDocument( + appId, + idToken, + project, + 'secrets', + EnsembleDocumentType.Secrets, + JSON.stringify(payload.secrets), + 'secrets', + secretsDtoToStringMap(payload.secrets), + options + ); + } +} + function getCollaboratorRole( collaboratorsField: | { mapValue?: { fields?: Record } } @@ -1028,6 +1244,8 @@ export async function fetchCloudApp( const translations: TranslationDTO[] = []; const assets: AssetDTO[] = []; let theme: ThemeDTO | undefined; + let config: ConfigDTO | undefined; + let secrets: SecretDTO | undefined; const i18nDocs: FirestoreDocument[] = []; for (const doc of artifacts) { const docId = getDocId(doc.name); @@ -1035,6 +1253,8 @@ export async function fetchCloudApp( if (type === 'screen') screens.push(toScreenDTO(doc)); else if (type === 'i18n') i18nDocs.push(doc); else if (type === 'asset') assets.push(toAssetDTO(doc)); + else if (type === 'config' || docId === 'appConfig') config = toConfigDTO(doc) ?? config; + else if (type === 'secrets' || docId === 'secrets') secrets = toSecretDTO(doc) ?? secrets; else if (type === 'theme') { if (!theme || docId === 'theme') { theme = toThemeDTO(doc); @@ -1065,6 +1285,8 @@ export async function fetchCloudApp( ...(theme && { theme }), ...(translations.length > 0 && { translations }), ...(assets.length > 0 && { assets }), + ...(config && { config }), + ...(secrets && { secrets }), }; } diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 46c0196..4344412 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -21,6 +21,7 @@ import { type RootManifest } from '../core/manifest.js'; import { writeVerboseJson } from '../core/debugFiles.js'; import { computePullPlan, type PullSummary } from '../core/sync.js'; import { applyCloudAssetsToFs, buildEnvConfigForCloudAssets } from '../core/pullAssets.js'; +import { applyCloudEnvToFs, readProjectEnvFiles } from '../core/envSync.js'; import { ui } from '../core/ui.js'; import { upsertEnvConfig } from '../core/envConfig.js'; @@ -267,6 +268,7 @@ export async function pullCommand(options: PullOptions = {}): Promise { localFiles, manifestExisting, enabledByProp, + localEnv: await readProjectEnvFiles(projectRoot, localFiles.assetFiles ?? []), }); if (plan.allArtifactsMatch && plan.manifestMatch) { @@ -372,6 +374,19 @@ export async function pullCommand(options: PullOptions = {}): Promise { `Some assets had invalid metadata and may be missing from .env.config (${envResult.failures.length}).` ); } + + await applyCloudEnvToFs( + projectRoot, + { + config: cloudApp.config, + secrets: cloudApp.secrets, + }, + (cloudApp.assets ?? []) + .map((asset) => asset.fileName) + .filter( + (fileName): fileName is string => typeof fileName === 'string' && fileName.length > 0 + ) + ); }); printPullSummary(pullSummary); diff --git a/src/commands/push.ts b/src/commands/push.ts index 5127f6d..e4b830b 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -6,6 +6,7 @@ import { checkAppAccess, fetchCloudApp, submitCliPush, + submitEnvDocumentsPush, FirestoreClientError, type FirestoreClientOptions, type CloudApp, @@ -21,6 +22,13 @@ import { withSpinner } from '../lib/spinner.js'; import { writeVerboseJson } from '../core/debugFiles.js'; import { computePushPlan, type PushSummary, type PushCounts } from '../core/sync.js'; import { buildAndWriteManifest } from '../core/manifest.js'; +import { + buildEnvPushDiff, + cloudHasNonAssetConfig, + cloudHasSecrets, + mergeConfigDtoForPush, + readProjectEnvFiles, +} from '../core/envSync.js'; import { ui } from '../core/ui.js'; export interface PushOptions { @@ -64,11 +72,14 @@ function yamlArtifactChangeTotal(summary: PushSummary): number { ); } -function printPushSummary(summary: PushSummary, options: { verbose?: boolean; isNoop?: boolean }) { +function printPushSummary( + summary: PushSummary, + options: { verbose?: boolean; isNoop?: boolean; envChanged?: boolean } +) { const { appName, environment, counts } = summary; const totalChanges = counts.created + counts.updated + counts.deleted; - if (options.isNoop || totalChanges === 0) { + if ((options.isNoop || totalChanges === 0) && !options.envChanged) { ui.info( `Pushed app "${appName}" to environment "${environment}" (no changes; already up to date).` ); @@ -79,6 +90,7 @@ function printPushSummary(summary: PushSummary, options: { verbose?: boolean; is if (counts.created > 0) parts.push(`${counts.created} created`); if (counts.updated > 0) parts.push(`${counts.updated} updated`); if (counts.deleted > 0) parts.push(`${counts.deleted} deleted`); + if (options.envChanged) parts.push('env files updated'); ui.success(`Pushed app "${appName}" to environment "${environment}" (${parts.join(', ')}).`); @@ -330,12 +342,47 @@ export async function pushCommand(options: PushOptions = {}): Promise { updatedBy, }); bundle = plan.bundle; + + const localEnv = await readProjectEnvFiles(root, data.assetFiles ?? []); + const assetFileNames = data.assetFiles ?? []; + const envPushDiff = buildEnvPushDiff( + localEnv, + { config: cloudApp.config, secrets: cloudApp.secrets }, + assetFileNames + ); + const { configChanged: envConfigChanged, secretsChanged: envSecretsChanged } = envPushDiff; + + if (!localEnv.envConfigPresent && cloudHasNonAssetConfig(cloudApp.config, assetFileNames)) { + ui.warn( + '.env.config is missing locally. Run `ensemble pull` to restore env vars from cloud. Config env push skipped.' + ); + } + if (!localEnv.envSecretsPresent && cloudHasSecrets(cloudApp.secrets)) { + ui.warn( + '.env.secrets is missing locally. Run `ensemble pull` to restore secrets from cloud. Secrets env push skipped.' + ); + } + const localConfigDto = envPushDiff.local.config; + const localSecretsDto = envPushDiff.local.secrets; + const pushConfigDto = + envConfigChanged && localConfigDto + ? mergeConfigDtoForPush(localConfigDto, cloudApp.config, data.assetFiles ?? []) + : localConfigDto; + await writeVerboseJson(root, 'ensemble-bundle.json', bundle, { verbose, }); - await writeVerboseJson(root, 'ensemble-diff.json', plan.diff, { - verbose, - }); + await writeVerboseJson( + root, + 'ensemble-diff.json', + { + ...plan.diff, + env: envPushDiff, + }, + { + verbose, + } + ); const summary = plan.summary; const yamlChangeTotal = yamlArtifactChangeTotal(summary); @@ -343,18 +390,41 @@ export async function pushCommand(options: PushOptions = {}): Promise { .map((item) => (item as { fileName?: string }).fileName) .filter((fn): fn is string => typeof fn === 'string' && fn.length > 0); - if (yamlChangeTotal === 0 && assetsToUpload.length === 0) { + if ( + yamlChangeTotal === 0 && + assetsToUpload.length === 0 && + !envConfigChanged && + !envSecretsChanged + ) { ui.info('Up to date. Nothing to push.'); return; } const pushPayload = buildPushPayload(bundle!, plan.diff, cloudApp, updatedBy); - await writeVerboseJson(root, 'ensemble-push-payload.json', pushPayload, { - verbose, - }); + await writeVerboseJson( + root, + 'ensemble-push-payload.json', + { + ...pushPayload, + ...(envConfigChanged || envSecretsChanged + ? { + env: { + ...(pushConfigDto && { config: pushConfigDto }), + ...(localSecretsDto && { secrets: localSecretsDto }), + }, + } + : {}), + }, + { + verbose, + } + ); if (options.dryRun) { printPushDryRun(plan.diff); + if (envConfigChanged || envSecretsChanged) { + ui.note('Env file changes would also be pushed (.env.config / .env.secrets).'); + } return; } @@ -363,6 +433,14 @@ export async function pushCommand(options: PushOptions = {}): Promise { // eslint-disable-next-line no-console console.log(line); } + if (envConfigChanged) { + // eslint-disable-next-line no-console + console.log(' env:\n ✏️ modified .env.config'); + } + if (envSecretsChanged) { + // eslint-disable-next-line no-console + console.log(' env:\n ✏️ modified .env.secrets'); + } const appHome = appConfig.appHome as string | undefined; const cloudHome = getCloudHomeScreenName(cloudApp); @@ -415,10 +493,12 @@ export async function pushCommand(options: PushOptions = {}): Promise { hasDeletes || largeChangeSet ? `This will delete ${summary.counts.deleted} item(s) and apply ${yamlCreatedOrUpdated} other change(s)${ assetsToUpload.length > 0 ? `, and upload ${assetsToUpload.length} asset(s)` : '' + }${ + envConfigChanged || envSecretsChanged ? ', and update env files' : '' }. Continue? [y/N]` : `Proceed with push${ assetsToUpload.length > 0 ? ` and upload ${assetsToUpload.length} asset(s)` : '' - }?`; + }${envConfigChanged || envSecretsChanged ? ' and update env files' : ''}?`; const { proceed } = await prompts({ type: 'confirm', @@ -451,6 +531,20 @@ export async function pushCommand(options: PushOptions = {}): Promise { } } + if (envConfigChanged || envSecretsChanged) { + await withSpinner('Pushing env files to cloud...', () => + submitEnvDocumentsPush( + appId, + idToken, + { + ...(pushConfigDto && { config: pushConfigDto }), + ...(localSecretsDto && { secrets: localSecretsDto }), + }, + firestoreOptions + ) + ); + } + if (manifestNeedsRefresh && bundle) { // Only refresh manifest when artifact changes can affect its contents. try { @@ -491,6 +585,10 @@ export async function pushCommand(options: PushOptions = {}): Promise { return; } - printPushSummary(summary, { verbose, isNoop: false }); + printPushSummary(summary, { + verbose, + isNoop: false, + envChanged: envConfigChanged || envSecretsChanged, + }); } } diff --git a/src/commands/release.ts b/src/commands/release.ts index b89d5dc..4f152a6 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -17,6 +17,11 @@ import { uploadReleaseSnapshot, } from '../cloud/storageClient.js'; import { applyCloudStateToFs } from '../core/applyToFs.js'; +import { + applyReleaseConfigToFs, + buildConfigDtoForReleaseSnapshot, + readProjectEnvFiles, +} from '../core/envSync.js'; import { buildDocumentsFromParsed } from '../core/buildDocuments.js'; import { ArtifactProps, type ArtifactProp } from '../core/artifacts.js'; import { collectAppFiles } from '../core/appCollector.js'; @@ -105,6 +110,8 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): const appName = (appConfig.name as string | undefined) ?? 'App'; const appHome = appConfig.appHome as string | undefined; const localFiles = await collectAppFiles(root); + const localEnv = await readProjectEnvFiles(root, localFiles.assetFiles ?? []); + const localConfig = buildConfigDtoForReleaseSnapshot(localEnv.envConfig); const localApp = buildDocumentsFromParsed(localFiles, appId, appName, appHome, undefined); const snapshot: CloudApp = { id: localApp.id, @@ -119,6 +126,7 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): localApp.translations.length > 0 && { translations: localApp.translations }), ...(localApp.theme && { theme: localApp.theme }), ...(localApp.assets && localApp.assets.length > 0 && { assets: localApp.assets }), + ...(localConfig && { config: localConfig }), }; try { @@ -384,6 +392,7 @@ export async function releaseUseCommand(options: ReleaseUseOptions = {}): Promis }, }) ); + await applyReleaseConfigToFs(projectRoot, snapshot.config); ui.success( 'Local files updated to selected release. Run "ensemble push" to apply to the cloud.' ); diff --git a/src/core/envConfig.ts b/src/core/envConfig.ts index f428eb9..5200a59 100644 --- a/src/core/envConfig.ts +++ b/src/core/envConfig.ts @@ -1,7 +1,13 @@ import fs from 'fs/promises'; import path from 'path'; -function parseEnvConfig(raw: string): { +export interface EnvEntry { + key: string; + value: string; + overwrite?: boolean; +} + +function parseEnvFile(raw: string): { lines: string[]; keyToLineIndex: Map; } { @@ -18,18 +24,47 @@ function parseEnvConfig(raw: string): { return { lines, keyToLineIndex }; } -export async function upsertEnvConfig( +export async function envFileExists(projectRoot: string, fileName: string): Promise { + try { + await fs.access(path.join(projectRoot, fileName)); + return true; + } catch { + return false; + } +} + +export async function readEnvFile(projectRoot: string, fileName: string): Promise { + const envPath = path.join(projectRoot, fileName); + let raw = ''; + try { + raw = await fs.readFile(envPath, 'utf8'); + } catch { + return []; + } + const parsed = parseEnvFile(raw); + const entries: EnvEntry[] = []; + for (const [key, lineIndex] of parsed.keyToLineIndex) { + const line = parsed.lines[lineIndex] ?? ''; + const eq = line.indexOf('='); + if (eq <= 0) continue; + entries.push({ key, value: line.slice(eq + 1) }); + } + return entries.sort((a, b) => a.key.localeCompare(b.key)); +} + +export async function upsertEnvFile( projectRoot: string, - entries: Array<{ key: string; value: string; overwrite?: boolean }> + fileName: string, + entries: EnvEntry[] ): Promise { - const envPath = path.join(projectRoot, '.env.config'); + const envPath = path.join(projectRoot, fileName); let raw = ''; try { raw = await fs.readFile(envPath, 'utf8'); } catch { raw = ''; } - const parsed = parseEnvConfig(raw); + const parsed = parseEnvFile(raw); while (parsed.lines.length > 0 && parsed.lines[parsed.lines.length - 1].trim() === '') { parsed.lines.pop(); } @@ -46,3 +81,20 @@ export async function upsertEnvConfig( const normalized = parsed.lines.join('\n').replace(/\n*$/, '\n'); await fs.writeFile(envPath, normalized, 'utf8'); } + +export async function writeEnvFile( + projectRoot: string, + fileName: string, + entries: EnvEntry[] +): Promise { + const envPath = path.join(projectRoot, fileName); + const normalized = entries + .map((entry) => `${entry.key}=${entry.value}`) + .join('\n') + .replace(/\n*$/, '\n'); + await fs.writeFile(envPath, normalized, 'utf8'); +} + +export async function upsertEnvConfig(projectRoot: string, entries: EnvEntry[]): Promise { + await upsertEnvFile(projectRoot, '.env.config', entries); +} diff --git a/src/core/envSync.ts b/src/core/envSync.ts new file mode 100644 index 0000000..6390d94 --- /dev/null +++ b/src/core/envSync.ts @@ -0,0 +1,309 @@ +import type { ConfigDTO, SecretDTO } from './dto.js'; +import { deriveAssetEnvKey } from './pullAssets.js'; +import { + envFileExists, + readEnvFile, + upsertEnvFile, + writeEnvFile, + type EnvEntry, +} from './envConfig.js'; + +export interface CloudEnvState { + config?: ConfigDTO; + secrets?: SecretDTO; +} + +export interface LocalEnvFiles { + envConfig: EnvEntry[]; + envSecrets: EnvEntry[]; + /** false when the file does not exist (distinct from an empty file). */ + envConfigPresent: boolean; + envSecretsPresent: boolean; +} + +export function configDtoToEnvEntries(config: ConfigDTO | undefined): EnvEntry[] { + const vars = config?.envVariables; + if (!vars || typeof vars !== 'object') return []; + return Object.entries(vars) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => ({ key, value: String(value) })) + .sort((a, b) => a.key.localeCompare(b.key)); +} + +export function secretsDtoToEnvEntries(secrets: SecretDTO | undefined): EnvEntry[] { + if (!secrets || typeof secrets !== 'object') return []; + const nested = + secrets.secrets && typeof secrets.secrets === 'object' + ? (secrets.secrets as Record) + : (secrets as Record); + return Object.entries(nested) + .filter(([key, value]) => key !== 'secrets' && value !== undefined && value !== null) + .map(([key, value]) => ({ key, value: String(value) })) + .sort((a, b) => a.key.localeCompare(b.key)); +} + +export function collectAssetEnvKeys(assetFileNames: string[] = []): Set { + return new Set(['assets', ...assetFileNames.map(deriveAssetEnvKey)]); +} + +export function stripAssetKeysFromConfigDto( + config: ConfigDTO | undefined, + assetFileNames: string[] = [] +): ConfigDTO | undefined { + const assetKeys = collectAssetEnvKeys(assetFileNames); + const envVariables: Record = {}; + const vars = config?.envVariables; + if (!vars || typeof vars !== 'object') return undefined; + for (const [key, value] of Object.entries(vars)) { + if (assetKeys.has(key) || value === undefined || value === null) continue; + envVariables[key] = String(value); + } + return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; +} + +export function buildConfigDtoFromEnvConfigFile( + entries: EnvEntry[], + assetFileNames: string[] = [] +): ConfigDTO | undefined { + const assetKeys = collectAssetEnvKeys(assetFileNames); + const envVariables: Record = {}; + for (const entry of entries) { + if (assetKeys.has(entry.key)) continue; + envVariables[entry.key] = entry.value; + } + return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; +} + +export function mergeConfigDtoForPush( + localNonAssetConfig: ConfigDTO | undefined, + cloudConfig: ConfigDTO | undefined, + assetFileNames: string[] = [] +): ConfigDTO { + const assetKeys = collectAssetEnvKeys(assetFileNames); + const merged: Record = {}; + + for (const [key, value] of Object.entries(cloudConfig?.envVariables ?? {})) { + if (assetKeys.has(key) && value !== undefined && value !== null) { + merged[key] = String(value); + } + } + + for (const [key, value] of Object.entries(localNonAssetConfig?.envVariables ?? {})) { + merged[key] = String(value); + } + + return { envVariables: merged }; +} + +export function buildSecretsDtoFromEnvSecretsFile(entries: EnvEntry[]): SecretDTO | undefined { + if (entries.length === 0) return undefined; + const secrets: Record = {}; + for (const entry of entries) { + secrets[entry.key] = entry.value; + } + return { secrets }; +} + +export async function readProjectEnvFiles( + projectRoot: string, + assetFileNames: string[] = [] +): Promise { + void assetFileNames; + const [envConfigPresent, envSecretsPresent] = await Promise.all([ + envFileExists(projectRoot, '.env.config'), + envFileExists(projectRoot, '.env.secrets'), + ]); + const envConfig = envConfigPresent ? await readEnvFile(projectRoot, '.env.config') : []; + const envSecrets = envSecretsPresent ? await readEnvFile(projectRoot, '.env.secrets') : []; + return { envConfig, envSecrets, envConfigPresent, envSecretsPresent }; +} + +export function cloudHasNonAssetConfig( + cloudConfig: ConfigDTO | undefined, + assetFileNames: string[] = [] +): boolean { + return configDtoToEnvEntries(stripAssetKeysFromConfigDto(cloudConfig, assetFileNames)).length > 0; +} + +export function cloudHasSecrets(cloudSecrets: SecretDTO | undefined): boolean { + return secretsDtoToEnvEntries(cloudSecrets).length > 0; +} + +function entriesEqual(a: EnvEntry[], b: EnvEntry[]): boolean { + const mapA = new Map(a.map((e) => [e.key, e.value])); + const mapB = new Map(b.map((e) => [e.key, e.value])); + if (mapA.size !== mapB.size) return false; + for (const [key, value] of mapA) { + if (mapB.get(key) !== value) return false; + } + return true; +} + +export function mergeAssetFileNamesForEnvCompare( + localAssetFileNames: string[] = [], + cloudAssets: Array<{ fileName?: string; isArchived?: boolean }> | undefined = [] +): string[] { + const fromCloud = (cloudAssets ?? []) + .filter((asset) => asset.isArchived !== true) + .map((asset) => asset.fileName) + .filter((fileName): fileName is string => typeof fileName === 'string' && fileName.length > 0); + return [...new Set([...localAssetFileNames, ...fromCloud])]; +} + +function assetEnvEntriesMatchCloud( + localEntries: EnvEntry[], + cloudConfig: ConfigDTO | undefined, + assetFileNames: string[] = [] +): boolean { + const assetKeys = collectAssetEnvKeys(assetFileNames); + const localMap = new Map(localEntries.map((entry) => [entry.key, entry.value])); + const cloudVars = cloudConfig?.envVariables ?? {}; + for (const key of assetKeys) { + const cloudValue = cloudVars[key]; + if (cloudValue === undefined || cloudValue === null) continue; + if (localMap.get(key) !== String(cloudValue)) return false; + } + return true; +} + +export function envConfigEntriesMatchCloud( + localEntries: EnvEntry[], + cloudConfig: ConfigDTO | undefined, + assetFileNames: string[] = [] +): boolean { + const localComparable = configDtoToEnvEntries( + buildConfigDtoFromEnvConfigFile(localEntries, assetFileNames) + ); + const cloudComparable = configDtoToEnvEntries( + stripAssetKeysFromConfigDto(cloudConfig, assetFileNames) + ); + return ( + entriesEqual(localComparable, cloudComparable) && + assetEnvEntriesMatchCloud(localEntries, cloudConfig, assetFileNames) + ); +} + +export function envSecretsEntriesMatchCloud( + localEntries: EnvEntry[], + cloudSecrets: SecretDTO | undefined +): boolean { + const localDto = buildSecretsDtoFromEnvSecretsFile(localEntries); + const cloudEntries = secretsDtoToEnvEntries(cloudSecrets); + const localComparable = secretsDtoToEnvEntries(localDto); + return entriesEqual(localComparable, cloudEntries); +} + +export interface EnvPushDiff { + configChanged: boolean; + secretsChanged: boolean; + local: { + config?: ConfigDTO; + secrets?: SecretDTO; + }; + cloud: { + config?: ConfigDTO; + secrets?: SecretDTO; + }; +} + +export function buildEnvPushDiff( + localEnv: LocalEnvFiles, + cloudEnv: CloudEnvState, + assetFileNames: string[] = [] +): EnvPushDiff { + const configFilePresent = localEnv.envConfigPresent ?? true; + const secretsFilePresent = localEnv.envSecretsPresent ?? true; + const configChanged = + configFilePresent && + !envConfigEntriesMatchCloud(localEnv.envConfig, cloudEnv.config, assetFileNames); + const secretsChanged = + secretsFilePresent && !envSecretsEntriesMatchCloud(localEnv.envSecrets, cloudEnv.secrets); + + return { + configChanged, + secretsChanged, + local: { + ...(configChanged && { + config: buildConfigDtoFromEnvConfigFile(localEnv.envConfig, assetFileNames) ?? { + envVariables: {}, + }, + }), + ...(secretsChanged && { + secrets: buildSecretsDtoFromEnvSecretsFile(localEnv.envSecrets), + }), + }, + cloud: { + ...(configChanged && + cloudEnv.config && { + config: stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames) ?? { + envVariables: {}, + }, + }), + ...(secretsChanged && cloudEnv.secrets && { secrets: cloudEnv.secrets }), + }, + }; +} + +export function buildConfigDtoForReleaseSnapshot(entries: EnvEntry[]): ConfigDTO | undefined { + if (entries.length === 0) return undefined; + const envVariables: Record = {}; + for (const entry of entries) { + envVariables[entry.key] = entry.value; + } + return { envVariables }; +} + +/** restores `.env.config` from a release snapshot; secrets are never included in releases */ +export async function applyReleaseConfigToFs( + projectRoot: string, + config: ConfigDTO | undefined +): Promise { + const configEntries = configDtoToEnvEntries(config); + if (configEntries.length > 0) { + await writeEnvFile(projectRoot, '.env.config', configEntries); + } +} + +export async function applyCloudEnvToFs( + projectRoot: string, + cloudEnv: CloudEnvState, + assetFileNames: string[] = [] +): Promise { + await upsertCloudAssetConfigEntries(projectRoot, cloudEnv.config, assetFileNames); + + const configEntries = configDtoToEnvEntries( + stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames) + ); + await syncEnvConfigNonAssetEntries(projectRoot, configEntries, assetFileNames); + + const secretEntries = secretsDtoToEnvEntries(cloudEnv.secrets); + await writeEnvFile(projectRoot, '.env.secrets', secretEntries); +} + +async function upsertCloudAssetConfigEntries( + projectRoot: string, + cloudConfig: ConfigDTO | undefined, + assetFileNames: string[] = [] +): Promise { + const assetKeys = collectAssetEnvKeys(assetFileNames); + const vars = cloudConfig?.envVariables ?? {}; + const entries: EnvEntry[] = []; + for (const [key, value] of Object.entries(vars)) { + if (!assetKeys.has(key) || value === undefined || value === null) continue; + entries.push({ key, value: String(value) }); + } + if (entries.length > 0) { + await upsertEnvFile(projectRoot, '.env.config', entries); + } +} + +async function syncEnvConfigNonAssetEntries( + projectRoot: string, + nonAssetEntries: EnvEntry[], + assetFileNames: string[] = [] +): Promise { + const assetKeys = collectAssetEnvKeys(assetFileNames); + const existing = await readEnvFile(projectRoot, '.env.config'); + const assetEntries = existing.filter((entry) => assetKeys.has(entry.key)); + await writeEnvFile(projectRoot, '.env.config', [...assetEntries, ...nonAssetEntries]); +} diff --git a/src/core/pullAssets.ts b/src/core/pullAssets.ts index 2bfa32a..61292f8 100644 --- a/src/core/pullAssets.ts +++ b/src/core/pullAssets.ts @@ -72,8 +72,7 @@ function extractEnvKeyFromCopyText(copyText: string | undefined): string | undef return nonAssets.length === 1 ? nonAssets[0] : (nonAssets[0] ?? unique[0]); } -function deriveEnvKeyFromFileName(fileName: string): string { - // Match the common behavior used in tests/mocks: replace non-word characters with underscores. +export function deriveAssetEnvKey(fileName: string): string { return fileName.replace(/[^\w]+/g, '_'); } @@ -144,7 +143,7 @@ export function buildEnvConfigForCloudAssets( const derived = tryDeriveAssetBaseAndValue(url, fileName); if (!derived) continue; - const envKey = extractEnvKeyFromCopyText(a.copyText) ?? deriveEnvKeyFromFileName(fileName); + const envKey = extractEnvKeyFromCopyText(a.copyText) ?? deriveAssetEnvKey(fileName); derivedByFile.set(fileName, { ...derived, envKey, fileName }); baseCounts.set(derived.baseUrl, (baseCounts.get(derived.baseUrl) ?? 0) + 1); } diff --git a/src/core/sync.ts b/src/core/sync.ts index cfc7f40..32451b9 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -1,6 +1,12 @@ import type { CloudApp } from '../cloud/firestoreClient.js'; import type { ParsedAppFiles } from './appCollector.js'; import type { ApplicationDTO } from './dto.js'; +import { + envConfigEntriesMatchCloud, + envSecretsEntriesMatchCloud, + mergeAssetFileNamesForEnvCompare, + type LocalEnvFiles, +} from './envSync.js'; import { ArtifactProps, type ArtifactProp, @@ -213,6 +219,7 @@ export interface ComputePullPlanArgs { localFiles: ParsedAppFiles; manifestExisting: RootManifest; enabledByProp: Record; + localEnv?: LocalEnvFiles; } export function computePullPlan({ @@ -222,6 +229,7 @@ export function computePullPlan({ localFiles, manifestExisting, enabledByProp, + localEnv, }: ComputePullPlanArgs): PullPlan { const matchesByProp: Partial> = {}; let assetsMatch = true; @@ -309,8 +317,20 @@ export function computePullPlan({ } } + const envAssetFileNames = mergeAssetFileNamesForEnvCompare( + localFiles.assetFiles ?? [], + cloudApp.assets + ); + const envConfigMatch = envConfigEntriesMatchCloud( + localEnv?.envConfig ?? [], + cloudApp.config, + envAssetFileNames + ); + const envSecretsMatch = envSecretsEntriesMatchCloud(localEnv?.envSecrets ?? [], cloudApp.secrets); + const envMatch = envConfigMatch && envSecretsMatch; + const allArtifactsMatch = - ArtifactProps.every((prop) => matchesByProp[prop] ?? true) && assetsMatch; + ArtifactProps.every((prop) => matchesByProp[prop] ?? true) && assetsMatch && envMatch; const changes: PullChange[] = []; let createdCount = 0; @@ -438,6 +458,15 @@ export function computePullPlan({ ).length; skippedCount += missingPublicUrl; + if (!envConfigMatch) { + updatedCount += 1; + changes.push({ kind: 'env', file: '.env.config', operation: 'update' }); + } + if (!envSecretsMatch) { + updatedCount += 1; + changes.push({ kind: 'env', file: '.env.secrets', operation: 'update' }); + } + const summary: PullSummary = { appName, environment, diff --git a/tests/cloud/firestoreClient.test.ts b/tests/cloud/firestoreClient.test.ts index 8447d5b..d980f38 100644 --- a/tests/cloud/firestoreClient.test.ts +++ b/tests/cloud/firestoreClient.test.ts @@ -4,6 +4,7 @@ import { fetchCloudApp, fetchRootScreenName, submitCliPush, + submitEnvDocumentsPush, listVersions, createVersion, getVersion, @@ -314,6 +315,168 @@ describe('fetchCloudApp', () => { expect(result.theme).toBeDefined(); expect(result.theme?.content).toBe('random theme'); }); + + it('fetches appConfig and secrets artifacts into config and secrets', async () => { + const appDoc = { + name: 'projects/p/databases/(default)/documents/apps/app-1', + }; + + const artifacts = [ + { + name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/appConfig', + fields: { + type: { stringValue: 'config' }, + name: { stringValue: 'appConfig' }, + content: { + stringValue: JSON.stringify({ envVariables: { API_URL: 'https://api.example.com' } }), + }, + }, + }, + { + name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/secrets', + fields: { + type: { stringValue: 'secrets' }, + name: { stringValue: 'secrets' }, + content: { stringValue: JSON.stringify({ secrets: { S1: 'secret-value' } }) }, + }, + }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes('/documents/apps/app-1') && !urlStr.includes('/artifacts')) { + return new Response(JSON.stringify(appDoc), { status: 200 }); + } + if (urlStr.includes('/artifacts')) { + return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); + } + if (urlStr.includes('internal_artifacts')) { + return new Response(JSON.stringify({ documents: [] }), { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + const result = await fetchCloudApp('app-1', 'token'); + expect(result.config?.envVariables?.API_URL).toBe('https://api.example.com'); + expect(result.secrets?.secrets).toEqual({ S1: 'secret-value' }); + }); + + it('prefers native Firestore envVariables map over JSON content for config', async () => { + const appDoc = { + name: 'projects/p/databases/(default)/documents/apps/app-1', + }; + + const artifacts = [ + { + name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/appConfig', + fields: { + type: { stringValue: 'config' }, + name: { stringValue: 'appConfig' }, + content: { + stringValue: JSON.stringify({ envVariables: { E1: 'from-content' } }), + }, + envVariables: { + mapValue: { + fields: { + E1: { stringValue: 'from-map' }, + }, + }, + }, + }, + }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes('/documents/apps/app-1') && !urlStr.includes('/artifacts')) { + return new Response(JSON.stringify(appDoc), { status: 200 }); + } + if (urlStr.includes('/artifacts')) { + return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); + } + if (urlStr.includes('internal_artifacts')) { + return new Response(JSON.stringify({ documents: [] }), { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + const result = await fetchCloudApp('app-1', 'token'); + expect(result.config?.envVariables?.E1).toBe('from-map'); + }); +}); + +describe('submitEnvDocumentsPush', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('patches content, envVariables map, and updatedAt for appConfig', async () => { + let capturedPatch: { url: string; body: string } | null = null; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes('/artifacts/appConfig') && init?.method === 'PATCH') { + capturedPatch = { url: urlStr, body: (init.body as string) ?? '' }; + return new Response('{}', { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + await submitEnvDocumentsPush('app-1', 'token', { + config: { envVariables: { E1: 'EV11', assets: 'https://cdn.example.com/' } }, + }); + + expect(capturedPatch).not.toBeNull(); + expect(capturedPatch!.url).toContain('updateMask.fieldPaths=content'); + expect(capturedPatch!.url).toContain('updateMask.fieldPaths=envVariables'); + expect(capturedPatch!.url).toContain('updateMask.fieldPaths=updatedAt'); + const body = JSON.parse(capturedPatch!.body) as { + fields: { + content?: { stringValue?: string }; + envVariables?: { mapValue?: { fields?: Record } }; + }; + }; + expect(JSON.parse(body.fields.content?.stringValue ?? '{}')).toEqual({ + envVariables: { E1: 'EV11', assets: 'https://cdn.example.com/' }, + }); + expect(body.fields.envVariables?.mapValue?.fields?.E1?.stringValue).toBe('EV11'); + expect(body.fields.envVariables?.mapValue?.fields?.assets?.stringValue).toBe( + 'https://cdn.example.com/' + ); + }); + + it('patches content, secrets map, and updatedAt for secrets', async () => { + let capturedPatch: { url: string; body: string } | null = null; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes('/artifacts/secrets') && init?.method === 'PATCH') { + capturedPatch = { url: urlStr, body: (init.body as string) ?? '' }; + return new Response('{}', { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + await submitEnvDocumentsPush('app-1', 'token', { + secrets: { secrets: { S1: 'SK1', S2: 'SK22' } }, + }); + + expect(capturedPatch).not.toBeNull(); + expect(capturedPatch!.url).toContain('updateMask.fieldPaths=secrets'); + const body = JSON.parse(capturedPatch!.body) as { + fields: { + secrets?: { mapValue?: { fields?: Record } }; + }; + }; + expect(body.fields.secrets?.mapValue?.fields?.S2?.stringValue).toBe('SK22'); + }); }); describe('fetchRootScreenName', () => { diff --git a/tests/commands/pushPull.test.ts b/tests/commands/pushPull.test.ts index 1a7cabd..a1d8034 100644 --- a/tests/commands/pushPull.test.ts +++ b/tests/commands/pushPull.test.ts @@ -82,6 +82,7 @@ const cloudModuleMock = vi.hoisted(() => { return { assetsUploaded: 0 }; } ), + submitEnvDocumentsPush: vi.fn(async () => {}), }; }); @@ -990,4 +991,91 @@ describe('push/pull integration (commands)', () => { process.exitCode = originalExitCode; errorSpy.mockRestore(); }); + + it('pull writes cloud config and secrets to .env.config and .env.secrets', async () => { + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + config: { envVariables: { API_URL: 'https://api.example.com' } }, + secrets: { secrets: { S1: 'secret-value' } }, + }); + + await pullCommand({ verbose: false, yes: true }); + + const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); + const envSecrets = await fs.readFile(path.join(projectRoot, '.env.secrets'), 'utf8'); + expect(envConfig).toContain('API_URL=https://api.example.com'); + expect(envSecrets).toContain('S1=secret-value'); + }); + + it('push uploads local env file changes to cloud', async () => { + await fs.writeFile( + path.join(projectRoot, '.env.config'), + 'API_URL=https://local.example.com\n', + 'utf8' + ); + await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=local-secret\n', 'utf8'); + + const resolveAppContextMock = resolveAppContext as unknown as ReturnType; + resolveAppContextMock.mockResolvedValueOnce({ + projectRoot, + config: { + default: 'dev', + apps: { + dev: { + appId: 'app1', + name: 'App', + appHome: undefined, + options: appOptionsRef.value, + }, + }, + }, + appKey: 'dev', + appId: 'app1', + }); + + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + config: { envVariables: { API_URL: 'https://cloud.example.com' } }, + secrets: { secrets: { S1: 'cloud-secret' } }, + }); + + await pushCommand({ verbose: true, yes: true }); + + const diffRaw = await fs.readFile(path.join(projectRoot, 'ensemble-diff.json'), 'utf8'); + const diff = JSON.parse(diffRaw) as { + env?: { + configChanged?: boolean; + local?: { config?: { envVariables?: Record } }; + }; + }; + expect(diff.env?.configChanged).toBe(true); + expect(diff.env?.local?.config?.envVariables?.API_URL).toBe('https://local.example.com'); + + const { submitEnvDocumentsPush } = cloudModuleMock as { + submitEnvDocumentsPush: ReturnType; + }; + expect(submitEnvDocumentsPush).toHaveBeenCalledTimes(1); + const [, , payload] = submitEnvDocumentsPush.mock.calls[0] as [ + string, + string, + { + config?: { envVariables?: Record }; + secrets?: { secrets?: Record }; + }, + ]; + expect(payload.config?.envVariables?.API_URL).toBe('https://local.example.com'); + expect(payload.secrets?.secrets?.S1).toBe('local-secret'); + }); }); diff --git a/tests/commands/release.test.ts b/tests/commands/release.test.ts index 825bde7..2d38955 100644 --- a/tests/commands/release.test.ts +++ b/tests/commands/release.test.ts @@ -91,12 +91,27 @@ import { releaseListCommand, releaseUseCommand, } from '../../src/commands/release.js'; +import type { CloudApp } from '../../src/cloud/firestoreClient.js'; + +async function writeEnvConfig(projectRoot: string, lines: string[]): Promise { + await fs.writeFile(path.join(projectRoot, '.env.config'), `${lines.join('\n')}\n`, 'utf8'); +} + +function snapshotFromUploadMock(): CloudApp { + expect(uploadReleaseSnapshotMock).toHaveBeenCalledTimes(1); + const snapshotJson = uploadReleaseSnapshotMock.mock.calls[0]?.[3]; + expect(typeof snapshotJson).toBe('string'); + return JSON.parse(snapshotJson as string) as CloudApp; +} describe('release commands', () => { + const originalCwd = process.cwd(); + beforeEach(async () => { projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ensemble-cli-release-')); projectRootRef.value = projectRoot; appOptionsRef.value = {}; + process.chdir(projectRoot); // Minimal app files for buildDocumentsFromParsed: appHome is "Home". await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); @@ -142,6 +157,7 @@ describe('release commands', () => { }); afterEach(async () => { + process.chdir(originalCwd); await fs.rm(projectRoot, { recursive: true, force: true }).catch(() => {}); process.exitCode = 0; vi.clearAllMocks(); @@ -157,6 +173,123 @@ describe('release commands', () => { ); }); + it('release create stores full .env.config in snapshot without secrets or asset publicUrl', async () => { + const assetsDir = path.join(projectRoot, 'assets'); + await fs.mkdir(assetsDir, { recursive: true }); + await fs.writeFile(path.join(assetsDir, 'logo.png'), 'png-bytes', 'utf8'); + await fs.writeFile(path.join(assetsDir, 'Case1_Working.png'), 'png-bytes', 'utf8'); + await writeEnvConfig(projectRoot, [ + 'assets=https://cdn.example.com/base/', + 'logo_png=logo.png?token=abc', + 'Case1_Working_png=Case1_Working.png?token=def', + 'E1=EV1', + ]); + await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=SK1\n', 'utf8'); + + await releaseCreateCommand({ message: 'complete env snapshot', yes: true }); + + const snapshot = snapshotFromUploadMock(); + expect(snapshot.config?.envVariables).toEqual({ + assets: 'https://cdn.example.com/base/', + logo_png: 'logo.png?token=abc', + Case1_Working_png: 'Case1_Working.png?token=def', + E1: 'EV1', + }); + expect(snapshot.secrets).toBeUndefined(); + + const assetsByFile = new Map((snapshot.assets ?? []).map((asset) => [asset.fileName, asset])); + expect(assetsByFile.get('logo.png')?.publicUrl).toBeUndefined(); + expect(assetsByFile.get('logo.png')?.copyText).toBeUndefined(); + expect(assetsByFile.get('Case1_Working.png')?.publicUrl).toBeUndefined(); + expect(assetsByFile.get('Case1_Working.png')?.copyText).toBeUndefined(); + }); + + it('release create leaves assets without publicUrl when .env.config omits per-asset keys', async () => { + const assetsDir = path.join(projectRoot, 'assets'); + await fs.mkdir(assetsDir, { recursive: true }); + await fs.writeFile(path.join(assetsDir, 'Case1_Working.png'), 'png-bytes', 'utf8'); + await writeEnvConfig(projectRoot, ['assets=https://cdn.example.com/base/', 'E1=EV1']); + + await releaseCreateCommand({ message: 'partial env snapshot', yes: true }); + + const snapshot = snapshotFromUploadMock(); + expect(snapshot.config?.envVariables).toEqual({ + assets: 'https://cdn.example.com/base/', + E1: 'EV1', + }); + expect(snapshot.config?.envVariables?.Case1_Working_png).toBeUndefined(); + + const asset = (snapshot.assets ?? []).find((item) => item.fileName === 'Case1_Working.png'); + expect(asset).toBeDefined(); + expect(asset?.publicUrl).toBeUndefined(); + expect(asset?.copyText).toBeUndefined(); + }); + + it('release use ignores secrets in snapshot and leaves local .env.secrets unchanged', async () => { + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + JSON.stringify({ + id: 'app1', + name: 'App', + screens: [], + config: { envVariables: { E1: 'EV1' } }, + secrets: { secrets: { S1: 'SNAPSHOT-SECRET' } }, + } satisfies CloudApp) + ); + await writeEnvConfig(projectRoot, ['E1=EV-WRONG']); + await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=LOCAL-SECRET\n', 'utf8'); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ hash: 'hash-1' }); + + const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); + const envSecrets = await fs.readFile(path.join(projectRoot, '.env.secrets'), 'utf8'); + expect(envConfig).toContain('E1=EV1'); + expect(envSecrets).toContain('S1=LOCAL-SECRET'); + expect(envSecrets).not.toContain('SNAPSHOT-SECRET'); + }); + + it('release use restores partial env when snapshot config omits per-asset keys', async () => { + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + JSON.stringify({ + id: 'app1', + name: 'App', + screens: [], + assets: [ + { + id: 'asset:Case1_Working.png', + name: 'Case1_Working.png', + fileName: 'Case1_Working.png', + content: '', + type: 'asset', + }, + ], + config: { + envVariables: { + assets: 'https://cdn.example.com/base/', + E1: 'EV1', + }, + }, + } satisfies CloudApp) + ); + await writeEnvConfig(projectRoot, [ + 'assets=https://cdn.example.com/old/', + 'Case1_Working_png=Case1_Working.png?token=old', + 'E1=EV-WRONG', + ]); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ hash: 'hash-1' }); + + const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); + expect(envConfig).toContain('assets=https://cdn.example.com/base/'); + expect(envConfig).toContain('E1=EV1'); + expect(envConfig).not.toContain('Case1_Working_png='); + }); + it('release list prints heading and lines when versions exist', async () => { await releaseListCommand({}); diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts new file mode 100644 index 0000000..92832d7 --- /dev/null +++ b/tests/core/envSync.test.ts @@ -0,0 +1,335 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { + applyCloudEnvToFs, + applyReleaseConfigToFs, + buildConfigDtoForReleaseSnapshot, + buildConfigDtoFromEnvConfigFile, + buildEnvPushDiff, + buildSecretsDtoFromEnvSecretsFile, + configDtoToEnvEntries, + envConfigEntriesMatchCloud, + envSecretsEntriesMatchCloud, + mergeConfigDtoForPush, + readProjectEnvFiles, + secretsDtoToEnvEntries, +} from '../../src/core/envSync.js'; +import type { ConfigDTO, SecretDTO } from '../../src/core/dto.js'; + +describe('envSync', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ensemble-envSync-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('maps cloud config envVariables to .env.config entries', () => { + const config: ConfigDTO = { envVariables: { API_URL: 'https://api.example.com' } }; + expect(configDtoToEnvEntries(config)).toEqual([ + { key: 'API_URL', value: 'https://api.example.com' }, + ]); + }); + + it('maps cloud secrets to .env.secrets entries', () => { + const secrets: SecretDTO = { secrets: { S1: 'secret-value' } }; + expect(secretsDtoToEnvEntries(secrets)).toEqual([{ key: 'S1', value: 'secret-value' }]); + }); + + it('writes cloud env vars and secrets to local env files on pull', async () => { + await applyCloudEnvToFs(tmpDir, { + config: { envVariables: { API_URL: 'https://api.example.com' } }, + secrets: { secrets: { S1: 'secret-value' } }, + }); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + const envSecrets = await fs.readFile(path.join(tmpDir, '.env.secrets'), 'utf8'); + expect(envConfig).toContain('API_URL=https://api.example.com'); + expect(envSecrets).toContain('S1=secret-value'); + }); + + it('preserves asset keys in .env.config when applying cloud config env vars', async () => { + await fs.writeFile( + path.join(tmpDir, '.env.config'), + 'assets=https://cdn.example.com/\nlogo_png=logo.png\n', + 'utf8' + ); + + await applyCloudEnvToFs( + tmpDir, + { + config: { envVariables: { API_URL: 'https://api.example.com' } }, + }, + ['logo.png'] + ); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + expect(envConfig).toContain('assets=https://cdn.example.com/'); + expect(envConfig).toContain('logo_png=logo.png'); + expect(envConfig).toContain('API_URL=https://api.example.com'); + }); + + it('reads local env files excluding asset keys from config payload', async () => { + await fs.writeFile( + path.join(tmpDir, '.env.config'), + [ + 'assets=https://cdn.example.com/', + 'logo_png=logo.png', + 'API_URL=https://local.example.com', + ].join('\n') + '\n', + 'utf8' + ); + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=local-secret\n', 'utf8'); + + const local = await readProjectEnvFiles(tmpDir, ['logo.png']); + expect(buildConfigDtoFromEnvConfigFile(local.envConfig, ['logo.png'])).toEqual({ + envVariables: { API_URL: 'https://local.example.com' }, + }); + expect(buildSecretsDtoFromEnvSecretsFile(local.envSecrets)).toEqual({ + secrets: { S1: 'local-secret' }, + }); + }); + + it('detects when local env files differ from cloud', async () => { + await fs.writeFile( + path.join(tmpDir, '.env.config'), + 'API_URL=https://local.example.com\n', + 'utf8' + ); + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=local-secret\n', 'utf8'); + + const local = await readProjectEnvFiles(tmpDir, []); + expect( + envConfigEntriesMatchCloud(local.envConfig, { + envVariables: { API_URL: 'https://cloud.example.com' }, + }) + ).toBe(false); + expect(envSecretsEntriesMatchCloud(local.envSecrets, { secrets: { S1: 'cloud-secret' } })).toBe( + false + ); + }); + + it('builds env push diff with local and cloud values', async () => { + const local = { + envConfig: [{ key: 'API_URL', value: 'https://local.example.com' }], + envSecrets: [{ key: 'S1', value: 'local-secret' }], + }; + const diff = buildEnvPushDiff( + local, + { + config: { envVariables: { API_URL: 'https://cloud.example.com' } }, + secrets: { secrets: { S1: 'cloud-secret' } }, + }, + [] + ); + expect(diff.configChanged).toBe(true); + expect(diff.secretsChanged).toBe(true); + expect(diff.local.config?.envVariables?.API_URL).toBe('https://local.example.com'); + expect(diff.cloud.config?.envVariables?.API_URL).toBe('https://cloud.example.com'); + }); + + it('compares asset env key values when cloud config includes them', () => { + const local = { + envConfig: [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'logo_png', value: 'logo.png' }, + { key: 'E1', value: 'EV1' }, + { key: 'E2', value: 'EV2' }, + ], + envSecrets: [], + }; + const cloudConfig: ConfigDTO = { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?token=abc', + E1: 'EV1', + E2: 'EV2', + }, + }; + expect(envConfigEntriesMatchCloud(local.envConfig, cloudConfig, ['logo.png'])).toBe(false); + + const matchingLocal = { + envConfig: [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'logo_png', value: 'logo.png?token=abc' }, + { key: 'E1', value: 'EV1' }, + { key: 'E2', value: 'EV2' }, + ], + envSecrets: [], + }; + expect(envConfigEntriesMatchCloud(matchingLocal.envConfig, cloudConfig, ['logo.png'])).toBe( + true + ); + }); + + it('strips asset keys from cloud side of env push diff', () => { + const diff = buildEnvPushDiff( + { + envConfig: [{ key: 'E1', value: 'EV11' }], + envSecrets: [], + }, + { + config: { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png', + E1: 'EV1', + E2: 'EV2', + }, + }, + }, + ['logo.png'] + ); + expect(diff.configChanged).toBe(true); + expect(diff.cloud.config?.envVariables).toEqual({ E1: 'EV1', E2: 'EV2' }); + expect(diff.local.config?.envVariables).toEqual({ E1: 'EV11' }); + }); + + it('removes deleted non-asset keys from .env.config when cloud no longer has them', async () => { + await fs.writeFile( + path.join(tmpDir, '.env.config'), + 'assets=https://cdn.example.com/\nlogo_png=logo.png\nE1=EV1\nE2=EV2\n', + 'utf8' + ); + + await applyCloudEnvToFs(tmpDir, { config: { envVariables: { E1: 'EV1' } } }, ['logo.png']); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + expect(envConfig).toContain('E1=EV1'); + expect(envConfig).not.toContain('E2='); + expect(envConfig).toContain('assets=https://cdn.example.com/'); + expect(envConfig).toContain('logo_png=logo.png'); + }); + + it('merges local env vars with cloud asset keys for push payload', () => { + const merged = mergeConfigDtoForPush( + { envVariables: { E1: 'EV11', E2: 'EV2' } }, + { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?token=abc', + E1: 'EV1', + }, + }, + ['logo.png'] + ); + expect(merged.envVariables).toEqual({ + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?token=abc', + E1: 'EV11', + E2: 'EV2', + }); + }); + + it('drops removed non-asset keys from push payload', () => { + const merged = mergeConfigDtoForPush( + { envVariables: { E1: 'EV11' } }, + { + envVariables: { + assets: 'https://cdn.example.com/', + E1: 'EV1', + E2: 'EV2', + }, + }, + ['logo.png'] + ); + expect(merged.envVariables).toEqual({ + assets: 'https://cdn.example.com/', + E1: 'EV11', + }); + }); + + it('buildConfigDtoForReleaseSnapshot includes asset keys from .env.config', () => { + const dto = buildConfigDtoForReleaseSnapshot([ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'logo_png', value: 'logo.png?token=abc' }, + { key: 'E1', value: 'EV1' }, + ]); + expect(dto?.envVariables).toEqual({ + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?token=abc', + E1: 'EV1', + }); + }); + + it('applyReleaseConfigToFs restores .env.config from snapshot config only', async () => { + await applyReleaseConfigToFs(tmpDir, { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png', + E1: 'EV1', + }, + }); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + expect(envConfig).toContain('assets=https://cdn.example.com/'); + expect(envConfig).toContain('logo_png=logo.png'); + expect(envConfig).toContain('E1=EV1'); + }); + + it('detects missing asset env keys in .env.config when comparing pull state', () => { + expect( + envConfigEntriesMatchCloud( + [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'E1', value: 'EV111' }, + ], + { + envVariables: { + assets: 'https://cdn.example.com/', + Case1_Working_png: 'Case1_Working.png?alt=media&token=abc', + E1: 'EV111', + }, + }, + ['Case1_Working.png'] + ) + ).toBe(false); + }); + + it('does not treat missing env files as empty local deletes on push', () => { + const diff = buildEnvPushDiff( + { + envConfig: [], + envSecrets: [], + envConfigPresent: false, + envSecretsPresent: false, + }, + { + config: { envVariables: { E1: 'EV1' } }, + secrets: { secrets: { S1: 'SK1' } }, + }, + [] + ); + expect(diff.configChanged).toBe(false); + expect(diff.secretsChanged).toBe(false); + expect(diff.local).toEqual({}); + expect(diff.cloud).toEqual({}); + }); + + it('omits local and cloud env snapshots when already in sync', () => { + const diff = buildEnvPushDiff( + { + envConfig: [{ key: 'E1', value: 'EV1' }], + envSecrets: [{ key: 'S1', value: 'SK1' }], + envConfigPresent: true, + envSecretsPresent: true, + }, + { + config: { envVariables: { E1: 'EV1' } }, + secrets: { secrets: { S1: 'SK1' } }, + }, + [] + ); + expect(diff.configChanged).toBe(false); + expect(diff.secretsChanged).toBe(false); + expect(diff.local).toEqual({}); + expect(diff.cloud).toEqual({}); + }); +}); From c3cf338fcfc6c048c58153b105d5d608add7bc4d Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Tue, 16 Jun 2026 15:17:17 +0500 Subject: [PATCH 02/10] refactor(env): simplify env sync module - Remove unused assetFileNames param from readProjectEnvFiles - Consolidate config DTO builders via shared helpers - Extract missing-env-file push warnings into envSync --- src/commands/pull.ts | 2 +- src/commands/push.ts | 21 ++++---- src/commands/release.ts | 2 +- src/core/envSync.ts | 98 ++++++++++++++++++++++---------------- tests/core/envSync.test.ts | 26 +++++++++- 5 files changed, 90 insertions(+), 59 deletions(-) diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 4344412..9271a2a 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -268,7 +268,7 @@ export async function pullCommand(options: PullOptions = {}): Promise { localFiles, manifestExisting, enabledByProp, - localEnv: await readProjectEnvFiles(projectRoot, localFiles.assetFiles ?? []), + localEnv: await readProjectEnvFiles(projectRoot), }); if (plan.allArtifactsMatch && plan.manifestMatch) { diff --git a/src/commands/push.ts b/src/commands/push.ts index e4b830b..ee1e5a8 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -24,10 +24,9 @@ import { computePushPlan, type PushSummary, type PushCounts } from '../core/sync import { buildAndWriteManifest } from '../core/manifest.js'; import { buildEnvPushDiff, - cloudHasNonAssetConfig, - cloudHasSecrets, mergeConfigDtoForPush, readProjectEnvFiles, + warnIfMissingEnvFilesForPush, } from '../core/envSync.js'; import { ui } from '../core/ui.js'; @@ -343,7 +342,7 @@ export async function pushCommand(options: PushOptions = {}): Promise { }); bundle = plan.bundle; - const localEnv = await readProjectEnvFiles(root, data.assetFiles ?? []); + const localEnv = await readProjectEnvFiles(root); const assetFileNames = data.assetFiles ?? []; const envPushDiff = buildEnvPushDiff( localEnv, @@ -352,16 +351,12 @@ export async function pushCommand(options: PushOptions = {}): Promise { ); const { configChanged: envConfigChanged, secretsChanged: envSecretsChanged } = envPushDiff; - if (!localEnv.envConfigPresent && cloudHasNonAssetConfig(cloudApp.config, assetFileNames)) { - ui.warn( - '.env.config is missing locally. Run `ensemble pull` to restore env vars from cloud. Config env push skipped.' - ); - } - if (!localEnv.envSecretsPresent && cloudHasSecrets(cloudApp.secrets)) { - ui.warn( - '.env.secrets is missing locally. Run `ensemble pull` to restore secrets from cloud. Secrets env push skipped.' - ); - } + warnIfMissingEnvFilesForPush( + localEnv, + { config: cloudApp.config, secrets: cloudApp.secrets }, + assetFileNames, + (message) => ui.warn(message) + ); const localConfigDto = envPushDiff.local.config; const localSecretsDto = envPushDiff.local.secrets; const pushConfigDto = diff --git a/src/commands/release.ts b/src/commands/release.ts index 4f152a6..c30c10a 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -110,7 +110,7 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): const appName = (appConfig.name as string | undefined) ?? 'App'; const appHome = appConfig.appHome as string | undefined; const localFiles = await collectAppFiles(root); - const localEnv = await readProjectEnvFiles(root, localFiles.assetFiles ?? []); + const localEnv = await readProjectEnvFiles(root); const localConfig = buildConfigDtoForReleaseSnapshot(localEnv.envConfig); const localApp = buildDocumentsFromParsed(localFiles, appId, appName, appHome, undefined); const snapshot: CloudApp = { diff --git a/src/core/envSync.ts b/src/core/envSync.ts index 6390d94..7fe26ee 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -46,53 +46,62 @@ export function collectAssetEnvKeys(assetFileNames: string[] = []): Set return new Set(['assets', ...assetFileNames.map(deriveAssetEnvKey)]); } -export function stripAssetKeysFromConfigDto( +function filterConfigEnvVariables( config: ConfigDTO | undefined, - assetFileNames: string[] = [] -): ConfigDTO | undefined { - const assetKeys = collectAssetEnvKeys(assetFileNames); - const envVariables: Record = {}; + includeKey: (key: string) => boolean +): Record { const vars = config?.envVariables; - if (!vars || typeof vars !== 'object') return undefined; + if (!vars || typeof vars !== 'object') return {}; + const envVariables: Record = {}; for (const [key, value] of Object.entries(vars)) { - if (assetKeys.has(key) || value === undefined || value === null) continue; + if (!includeKey(key) || value === undefined || value === null) continue; envVariables[key] = String(value); } - return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; + return envVariables; } -export function buildConfigDtoFromEnvConfigFile( +function buildConfigDtoFromEntries( entries: EnvEntry[], - assetFileNames: string[] = [] + options?: { excludeKeys?: Set } ): ConfigDTO | undefined { - const assetKeys = collectAssetEnvKeys(assetFileNames); const envVariables: Record = {}; for (const entry of entries) { - if (assetKeys.has(entry.key)) continue; + if (options?.excludeKeys?.has(entry.key)) continue; envVariables[entry.key] = entry.value; } return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; } +export function stripAssetKeysFromConfigDto( + config: ConfigDTO | undefined, + assetFileNames: string[] = [] +): ConfigDTO | undefined { + const assetKeys = collectAssetEnvKeys(assetFileNames); + const envVariables = filterConfigEnvVariables(config, (key) => !assetKeys.has(key)); + return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; +} + +export function buildConfigDtoFromEnvConfigFile( + entries: EnvEntry[], + assetFileNames: string[] = [] +): ConfigDTO | undefined { + return buildConfigDtoFromEntries(entries, { + excludeKeys: collectAssetEnvKeys(assetFileNames), + }); +} + export function mergeConfigDtoForPush( localNonAssetConfig: ConfigDTO | undefined, cloudConfig: ConfigDTO | undefined, assetFileNames: string[] = [] ): ConfigDTO { const assetKeys = collectAssetEnvKeys(assetFileNames); - const merged: Record = {}; - - for (const [key, value] of Object.entries(cloudConfig?.envVariables ?? {})) { - if (assetKeys.has(key) && value !== undefined && value !== null) { - merged[key] = String(value); - } - } - - for (const [key, value] of Object.entries(localNonAssetConfig?.envVariables ?? {})) { - merged[key] = String(value); - } - - return { envVariables: merged }; + return { + envVariables: { + ...filterConfigEnvVariables(cloudConfig, (key) => assetKeys.has(key)), + ...(localNonAssetConfig?.envVariables ?? {}), + }, + }; } export function buildSecretsDtoFromEnvSecretsFile(entries: EnvEntry[]): SecretDTO | undefined { @@ -104,11 +113,25 @@ export function buildSecretsDtoFromEnvSecretsFile(entries: EnvEntry[]): SecretDT return { secrets }; } -export async function readProjectEnvFiles( - projectRoot: string, - assetFileNames: string[] = [] -): Promise { - void assetFileNames; +export function warnIfMissingEnvFilesForPush( + localEnv: LocalEnvFiles, + cloudEnv: CloudEnvState, + assetFileNames: string[] = [], + warn: (message: string) => void +): void { + if (!localEnv.envConfigPresent && cloudHasNonAssetConfig(cloudEnv.config, assetFileNames)) { + warn( + '.env.config is missing locally. Run `ensemble pull` to restore env vars from cloud. Config env push skipped.' + ); + } + if (!localEnv.envSecretsPresent && cloudHasSecrets(cloudEnv.secrets)) { + warn( + '.env.secrets is missing locally. Run `ensemble pull` to restore secrets from cloud. Secrets env push skipped.' + ); + } +} + +export async function readProjectEnvFiles(projectRoot: string): Promise { const [envConfigPresent, envSecretsPresent] = await Promise.all([ envFileExists(projectRoot, '.env.config'), envFileExists(projectRoot, '.env.secrets'), @@ -245,12 +268,7 @@ export function buildEnvPushDiff( } export function buildConfigDtoForReleaseSnapshot(entries: EnvEntry[]): ConfigDTO | undefined { - if (entries.length === 0) return undefined; - const envVariables: Record = {}; - for (const entry of entries) { - envVariables[entry.key] = entry.value; - } - return { envVariables }; + return buildConfigDtoFromEntries(entries); } /** restores `.env.config` from a release snapshot; secrets are never included in releases */ @@ -286,12 +304,8 @@ async function upsertCloudAssetConfigEntries( assetFileNames: string[] = [] ): Promise { const assetKeys = collectAssetEnvKeys(assetFileNames); - const vars = cloudConfig?.envVariables ?? {}; - const entries: EnvEntry[] = []; - for (const [key, value] of Object.entries(vars)) { - if (!assetKeys.has(key) || value === undefined || value === null) continue; - entries.push({ key, value: String(value) }); - } + const envVariables = filterConfigEnvVariables(cloudConfig, (key) => assetKeys.has(key)); + const entries = Object.entries(envVariables).map(([key, value]) => ({ key, value })); if (entries.length > 0) { await upsertEnvFile(projectRoot, '.env.config', entries); } diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 92832d7..42cd723 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -17,6 +17,7 @@ import { mergeConfigDtoForPush, readProjectEnvFiles, secretsDtoToEnvEntries, + warnIfMissingEnvFilesForPush, } from '../../src/core/envSync.js'; import type { ConfigDTO, SecretDTO } from '../../src/core/dto.js'; @@ -88,7 +89,7 @@ describe('envSync', () => { ); await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=local-secret\n', 'utf8'); - const local = await readProjectEnvFiles(tmpDir, ['logo.png']); + const local = await readProjectEnvFiles(tmpDir); expect(buildConfigDtoFromEnvConfigFile(local.envConfig, ['logo.png'])).toEqual({ envVariables: { API_URL: 'https://local.example.com' }, }); @@ -105,7 +106,7 @@ describe('envSync', () => { ); await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=local-secret\n', 'utf8'); - const local = await readProjectEnvFiles(tmpDir, []); + const local = await readProjectEnvFiles(tmpDir); expect( envConfigEntriesMatchCloud(local.envConfig, { envVariables: { API_URL: 'https://cloud.example.com' }, @@ -293,6 +294,27 @@ describe('envSync', () => { ).toBe(false); }); + it('warns when env files are missing locally but cloud has values', () => { + const warnings: string[] = []; + warnIfMissingEnvFilesForPush( + { + envConfig: [], + envSecrets: [], + envConfigPresent: false, + envSecretsPresent: false, + }, + { + config: { envVariables: { E1: 'EV1' } }, + secrets: { secrets: { S1: 'SK1' } }, + }, + [], + (message) => warnings.push(message) + ); + expect(warnings).toHaveLength(2); + expect(warnings[0]).toContain('.env.config is missing'); + expect(warnings[1]).toContain('.env.secrets is missing'); + }); + it('does not treat missing env files as empty local deletes on push', () => { const diff = buildEnvPushDiff( { From 928cfe36640874823bef37ddc727914063e7bfcd Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Tue, 16 Jun 2026 15:24:22 +0500 Subject: [PATCH 03/10] refactor(env): extract pull/push env orchestration - Partition cloud config into asset vs non-asset env variables - Add prepareEnvPushState and computeEnvPullChanges helpers - Slim push and sync command env wiring --- src/commands/push.ts | 38 +++++------- src/core/envSync.ts | 115 ++++++++++++++++++++++++++++++------- src/core/sync.ts | 28 +++------ tests/core/envSync.test.ts | 46 +++++++++++++++ 4 files changed, 162 insertions(+), 65 deletions(-) diff --git a/src/commands/push.ts b/src/commands/push.ts index ee1e5a8..4d17f16 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -22,12 +22,7 @@ import { withSpinner } from '../lib/spinner.js'; import { writeVerboseJson } from '../core/debugFiles.js'; import { computePushPlan, type PushSummary, type PushCounts } from '../core/sync.js'; import { buildAndWriteManifest } from '../core/manifest.js'; -import { - buildEnvPushDiff, - mergeConfigDtoForPush, - readProjectEnvFiles, - warnIfMissingEnvFilesForPush, -} from '../core/envSync.js'; +import { prepareEnvPushState } from '../core/envSync.js'; import { ui } from '../core/ui.js'; export interface PushOptions { @@ -342,27 +337,20 @@ export async function pushCommand(options: PushOptions = {}): Promise { }); bundle = plan.bundle; - const localEnv = await readProjectEnvFiles(root); const assetFileNames = data.assetFiles ?? []; - const envPushDiff = buildEnvPushDiff( - localEnv, - { config: cloudApp.config, secrets: cloudApp.secrets }, - assetFileNames - ); - const { configChanged: envConfigChanged, secretsChanged: envSecretsChanged } = envPushDiff; - - warnIfMissingEnvFilesForPush( - localEnv, - { config: cloudApp.config, secrets: cloudApp.secrets }, + const envPush = await prepareEnvPushState({ + projectRoot: root, + cloudEnv: { config: cloudApp.config, secrets: cloudApp.secrets }, assetFileNames, - (message) => ui.warn(message) - ); - const localConfigDto = envPushDiff.local.config; - const localSecretsDto = envPushDiff.local.secrets; - const pushConfigDto = - envConfigChanged && localConfigDto - ? mergeConfigDtoForPush(localConfigDto, cloudApp.config, data.assetFiles ?? []) - : localConfigDto; + warn: (message) => ui.warn(message), + }); + const { + diff: envPushDiff, + configChanged: envConfigChanged, + secretsChanged: envSecretsChanged, + pushConfigDto, + pushSecretsDto: localSecretsDto, + } = envPush; await writeVerboseJson(root, 'ensemble-bundle.json', bundle, { verbose, diff --git a/src/core/envSync.ts b/src/core/envSync.ts index 7fe26ee..44e1f1f 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -46,18 +46,27 @@ export function collectAssetEnvKeys(assetFileNames: string[] = []): Set return new Set(['assets', ...assetFileNames.map(deriveAssetEnvKey)]); } -function filterConfigEnvVariables( +function partitionConfigEnvVariables( config: ConfigDTO | undefined, - includeKey: (key: string) => boolean -): Record { + assetFileNames: string[] = [] +): { asset: Record; nonAsset: Record } { + const assetKeys = collectAssetEnvKeys(assetFileNames); + const asset: Record = {}; + const nonAsset: Record = {}; const vars = config?.envVariables; - if (!vars || typeof vars !== 'object') return {}; - const envVariables: Record = {}; + if (!vars || typeof vars !== 'object') return { asset, nonAsset }; + for (const [key, value] of Object.entries(vars)) { - if (!includeKey(key) || value === undefined || value === null) continue; - envVariables[key] = String(value); + if (value === undefined || value === null) continue; + const normalized = String(value); + if (assetKeys.has(key)) asset[key] = normalized; + else nonAsset[key] = normalized; } - return envVariables; + return { asset, nonAsset }; +} + +function configDtoFromEnvVariables(envVariables: Record): ConfigDTO | undefined { + return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; } function buildConfigDtoFromEntries( @@ -76,9 +85,8 @@ export function stripAssetKeysFromConfigDto( config: ConfigDTO | undefined, assetFileNames: string[] = [] ): ConfigDTO | undefined { - const assetKeys = collectAssetEnvKeys(assetFileNames); - const envVariables = filterConfigEnvVariables(config, (key) => !assetKeys.has(key)); - return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; + const { nonAsset } = partitionConfigEnvVariables(config, assetFileNames); + return configDtoFromEnvVariables(nonAsset); } export function buildConfigDtoFromEnvConfigFile( @@ -95,10 +103,10 @@ export function mergeConfigDtoForPush( cloudConfig: ConfigDTO | undefined, assetFileNames: string[] = [] ): ConfigDTO { - const assetKeys = collectAssetEnvKeys(assetFileNames); + const { asset } = partitionConfigEnvVariables(cloudConfig, assetFileNames); return { envVariables: { - ...filterConfigEnvVariables(cloudConfig, (key) => assetKeys.has(key)), + ...asset, ...(localNonAssetConfig?.envVariables ?? {}), }, }; @@ -180,11 +188,11 @@ function assetEnvEntriesMatchCloud( ): boolean { const assetKeys = collectAssetEnvKeys(assetFileNames); const localMap = new Map(localEntries.map((entry) => [entry.key, entry.value])); - const cloudVars = cloudConfig?.envVariables ?? {}; + const { asset: cloudAssetVars } = partitionConfigEnvVariables(cloudConfig, assetFileNames); for (const key of assetKeys) { - const cloudValue = cloudVars[key]; - if (cloudValue === undefined || cloudValue === null) continue; - if (localMap.get(key) !== String(cloudValue)) return false; + const cloudValue = cloudAssetVars[key]; + if (cloudValue === undefined) continue; + if (localMap.get(key) !== cloudValue) return false; } return true; } @@ -271,6 +279,74 @@ export function buildConfigDtoForReleaseSnapshot(entries: EnvEntry[]): ConfigDTO return buildConfigDtoFromEntries(entries); } +export interface EnvPullChanges { + assetFileNames: string[]; + configMatch: boolean; + secretsMatch: boolean; + match: boolean; + filesToUpdate: Array<'.env.config' | '.env.secrets'>; +} + +export function computeEnvPullChanges( + localEnv: LocalEnvFiles | undefined, + cloudConfig: ConfigDTO | undefined, + cloudSecrets: SecretDTO | undefined, + localAssetFileNames: string[] = [], + cloudAssets: Array<{ fileName?: string; isArchived?: boolean }> | undefined = [] +): EnvPullChanges { + const assetFileNames = mergeAssetFileNamesForEnvCompare(localAssetFileNames, cloudAssets); + const configMatch = envConfigEntriesMatchCloud( + localEnv?.envConfig ?? [], + cloudConfig, + assetFileNames + ); + const secretsMatch = envSecretsEntriesMatchCloud(localEnv?.envSecrets ?? [], cloudSecrets); + const filesToUpdate: Array<'.env.config' | '.env.secrets'> = []; + if (!configMatch) filesToUpdate.push('.env.config'); + if (!secretsMatch) filesToUpdate.push('.env.secrets'); + return { + assetFileNames, + configMatch, + secretsMatch, + match: configMatch && secretsMatch, + filesToUpdate, + }; +} + +export interface EnvPushState { + localEnv: LocalEnvFiles; + diff: EnvPushDiff; + configChanged: boolean; + secretsChanged: boolean; + pushConfigDto?: ConfigDTO; + pushSecretsDto?: SecretDTO; +} + +export async function prepareEnvPushState(params: { + projectRoot: string; + cloudEnv: CloudEnvState; + assetFileNames: string[]; + warn: (message: string) => void; +}): Promise { + const localEnv = await readProjectEnvFiles(params.projectRoot); + const diff = buildEnvPushDiff(localEnv, params.cloudEnv, params.assetFileNames); + warnIfMissingEnvFilesForPush(localEnv, params.cloudEnv, params.assetFileNames, params.warn); + + const pushConfigDto = + diff.configChanged && diff.local.config + ? mergeConfigDtoForPush(diff.local.config, params.cloudEnv.config, params.assetFileNames) + : diff.local.config; + + return { + localEnv, + diff, + configChanged: diff.configChanged, + secretsChanged: diff.secretsChanged, + pushConfigDto, + pushSecretsDto: diff.local.secrets, + }; +} + /** restores `.env.config` from a release snapshot; secrets are never included in releases */ export async function applyReleaseConfigToFs( projectRoot: string, @@ -303,9 +379,8 @@ async function upsertCloudAssetConfigEntries( cloudConfig: ConfigDTO | undefined, assetFileNames: string[] = [] ): Promise { - const assetKeys = collectAssetEnvKeys(assetFileNames); - const envVariables = filterConfigEnvVariables(cloudConfig, (key) => assetKeys.has(key)); - const entries = Object.entries(envVariables).map(([key, value]) => ({ key, value })); + const { asset } = partitionConfigEnvVariables(cloudConfig, assetFileNames); + const entries = Object.entries(asset).map(([key, value]) => ({ key, value })); if (entries.length > 0) { await upsertEnvFile(projectRoot, '.env.config', entries); } diff --git a/src/core/sync.ts b/src/core/sync.ts index 32451b9..8b9f026 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -1,12 +1,7 @@ import type { CloudApp } from '../cloud/firestoreClient.js'; import type { ParsedAppFiles } from './appCollector.js'; import type { ApplicationDTO } from './dto.js'; -import { - envConfigEntriesMatchCloud, - envSecretsEntriesMatchCloud, - mergeAssetFileNamesForEnvCompare, - type LocalEnvFiles, -} from './envSync.js'; +import { computeEnvPullChanges, type LocalEnvFiles } from './envSync.js'; import { ArtifactProps, type ArtifactProp, @@ -317,17 +312,14 @@ export function computePullPlan({ } } - const envAssetFileNames = mergeAssetFileNamesForEnvCompare( + const envPull = computeEnvPullChanges( + localEnv, + cloudApp.config, + cloudApp.secrets, localFiles.assetFiles ?? [], cloudApp.assets ); - const envConfigMatch = envConfigEntriesMatchCloud( - localEnv?.envConfig ?? [], - cloudApp.config, - envAssetFileNames - ); - const envSecretsMatch = envSecretsEntriesMatchCloud(localEnv?.envSecrets ?? [], cloudApp.secrets); - const envMatch = envConfigMatch && envSecretsMatch; + const envMatch = envPull.match; const allArtifactsMatch = ArtifactProps.every((prop) => matchesByProp[prop] ?? true) && assetsMatch && envMatch; @@ -458,13 +450,9 @@ export function computePullPlan({ ).length; skippedCount += missingPublicUrl; - if (!envConfigMatch) { - updatedCount += 1; - changes.push({ kind: 'env', file: '.env.config', operation: 'update' }); - } - if (!envSecretsMatch) { + for (const envFile of envPull.filesToUpdate) { updatedCount += 1; - changes.push({ kind: 'env', file: '.env.secrets', operation: 'update' }); + changes.push({ kind: 'env', file: envFile, operation: 'update' }); } const summary: PullSummary = { diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 42cd723..3f5836d 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -11,10 +11,12 @@ import { buildConfigDtoFromEnvConfigFile, buildEnvPushDiff, buildSecretsDtoFromEnvSecretsFile, + computeEnvPullChanges, configDtoToEnvEntries, envConfigEntriesMatchCloud, envSecretsEntriesMatchCloud, mergeConfigDtoForPush, + prepareEnvPushState, readProjectEnvFiles, secretsDtoToEnvEntries, warnIfMissingEnvFilesForPush, @@ -294,6 +296,50 @@ describe('envSync', () => { ).toBe(false); }); + it('computeEnvPullChanges lists env files that differ from cloud', () => { + const result = computeEnvPullChanges( + { + envConfig: [{ key: 'E1', value: 'EV1' }], + envSecrets: [{ key: 'S1', value: 'SK1' }], + envConfigPresent: true, + envSecretsPresent: true, + }, + { envVariables: { E1: 'EV2', assets: 'https://cdn.example.com/' } }, + { secrets: { S1: 'SK1' } }, + ['logo.png'], + [{ fileName: 'logo.png' }] + ); + + expect(result.configMatch).toBe(false); + expect(result.secretsMatch).toBe(true); + expect(result.match).toBe(false); + expect(result.filesToUpdate).toEqual(['.env.config']); + }); + + it('prepareEnvPushState builds merged config for push', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=EV11\n', 'utf8'); + + const state = await prepareEnvPushState({ + projectRoot: tmpDir, + cloudEnv: { + config: { + envVariables: { + assets: 'https://cdn.example.com/', + E1: 'EV1', + }, + }, + }, + assetFileNames: [], + warn: () => {}, + }); + + expect(state.configChanged).toBe(true); + expect(state.pushConfigDto?.envVariables).toEqual({ + assets: 'https://cdn.example.com/', + E1: 'EV11', + }); + }); + it('warns when env files are missing locally but cloud has values', () => { const warnings: string[] = []; warnIfMissingEnvFilesForPush( From ccf9c367893be4d9ab113f093130378b9e9be88b Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Tue, 16 Jun 2026 15:56:23 +0500 Subject: [PATCH 04/10] refactor(env): trim tests and dedupe env sync --- src/cloud/firestoreClient.ts | 29 +- src/commands/push.ts | 10 +- src/commands/release.ts | 4 +- src/core/envSync.ts | 48 +--- tests/cloud/firestoreClient.test.ts | 131 +++------ tests/commands/pushPull.test.ts | 80 ++---- tests/commands/release.test.ts | 91 ++---- tests/core/envSync.test.ts | 430 ++++++++++------------------ 8 files changed, 250 insertions(+), 573 deletions(-) diff --git a/src/cloud/firestoreClient.ts b/src/cloud/firestoreClient.ts index ff2e87c..20282f0 100644 --- a/src/cloud/firestoreClient.ts +++ b/src/cloud/firestoreClient.ts @@ -16,6 +16,7 @@ import type { SecretDTO, } from '../core/dto.js'; import { EnsembleDocumentType } from '../core/dto.js'; +import { configDtoToEnvEntries, secretsDtoToEnvEntries } from '../core/envSync.js'; import { getArtifactConfig, type ArtifactProp } from '../core/artifacts.js'; import { processWithConcurrency } from '../core/concurrency.js'; import { uploadProjectAssetsForPush } from '../core/pushAssets.js'; @@ -809,28 +810,8 @@ function encodeFirestoreStringMap(values: Record): { return { mapValue: { fields } }; } -function configDtoToStringMap(config: ConfigDTO): Record { - const envVariables = config.envVariables ?? {}; - const result: Record = {}; - for (const [key, value] of Object.entries(envVariables)) { - if (value !== undefined && value !== null) { - result[key] = String(value); - } - } - return result; -} - -function secretsDtoToStringMap(secrets: SecretDTO): Record { - const nested = - secrets.secrets && typeof secrets.secrets === 'object' - ? (secrets.secrets as Record) - : (secrets as Record); - const result: Record = {}; - for (const [key, value] of Object.entries(nested)) { - if (key === 'secrets' || value === undefined || value === null) continue; - result[key] = String(value); - } - return result; +function dtoToStringMap(entries: Array<{ key: string; value: string }>): Record { + return Object.fromEntries(entries.map((entry) => [entry.key, entry.value])); } function parseJsonObjectField(content: string | undefined): T | undefined { @@ -970,7 +951,7 @@ export async function submitEnvDocumentsPush( EnsembleDocumentType.Environment, JSON.stringify(payload.config), 'envVariables', - configDtoToStringMap(payload.config), + dtoToStringMap(configDtoToEnvEntries(payload.config)), options ); } @@ -983,7 +964,7 @@ export async function submitEnvDocumentsPush( EnsembleDocumentType.Secrets, JSON.stringify(payload.secrets), 'secrets', - secretsDtoToStringMap(payload.secrets), + dtoToStringMap(secretsDtoToEnvEntries(payload.secrets)), options ); } diff --git a/src/commands/push.ts b/src/commands/push.ts index 4d17f16..82bf4fd 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -344,13 +344,9 @@ export async function pushCommand(options: PushOptions = {}): Promise { assetFileNames, warn: (message) => ui.warn(message), }); - const { - diff: envPushDiff, - configChanged: envConfigChanged, - secretsChanged: envSecretsChanged, - pushConfigDto, - pushSecretsDto: localSecretsDto, - } = envPush; + const { diff: envPushDiff, pushConfigDto, pushSecretsDto: localSecretsDto } = envPush; + const envConfigChanged = envPushDiff.configChanged; + const envSecretsChanged = envPushDiff.secretsChanged; await writeVerboseJson(root, 'ensemble-bundle.json', bundle, { verbose, diff --git a/src/commands/release.ts b/src/commands/release.ts index c30c10a..e20a3c7 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -19,7 +19,7 @@ import { import { applyCloudStateToFs } from '../core/applyToFs.js'; import { applyReleaseConfigToFs, - buildConfigDtoForReleaseSnapshot, + buildConfigDtoFromEnvEntries, readProjectEnvFiles, } from '../core/envSync.js'; import { buildDocumentsFromParsed } from '../core/buildDocuments.js'; @@ -111,7 +111,7 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): const appHome = appConfig.appHome as string | undefined; const localFiles = await collectAppFiles(root); const localEnv = await readProjectEnvFiles(root); - const localConfig = buildConfigDtoForReleaseSnapshot(localEnv.envConfig); + const localConfig = buildConfigDtoFromEnvEntries(localEnv.envConfig); const localApp = buildDocumentsFromParsed(localFiles, appId, appName, appHome, undefined); const snapshot: CloudApp = { id: localApp.id, diff --git a/src/core/envSync.ts b/src/core/envSync.ts index 44e1f1f..e3dbcfe 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -112,13 +112,16 @@ export function mergeConfigDtoForPush( }; } +function entriesToRecord(entries: EnvEntry[]): Record { + return Object.fromEntries(entries.map((entry) => [entry.key, entry.value])); +} + export function buildSecretsDtoFromEnvSecretsFile(entries: EnvEntry[]): SecretDTO | undefined { - if (entries.length === 0) return undefined; - const secrets: Record = {}; - for (const entry of entries) { - secrets[entry.key] = entry.value; - } - return { secrets }; + return entries.length > 0 ? { secrets: entriesToRecord(entries) } : undefined; +} + +export function buildConfigDtoFromEnvEntries(entries: EnvEntry[]): ConfigDTO | undefined { + return buildConfigDtoFromEntries(entries); } export function warnIfMissingEnvFilesForPush( @@ -127,12 +130,15 @@ export function warnIfMissingEnvFilesForPush( assetFileNames: string[] = [], warn: (message: string) => void ): void { - if (!localEnv.envConfigPresent && cloudHasNonAssetConfig(cloudEnv.config, assetFileNames)) { + if ( + !localEnv.envConfigPresent && + configDtoToEnvEntries(stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames)).length > 0 + ) { warn( '.env.config is missing locally. Run `ensemble pull` to restore env vars from cloud. Config env push skipped.' ); } - if (!localEnv.envSecretsPresent && cloudHasSecrets(cloudEnv.secrets)) { + if (!localEnv.envSecretsPresent && secretsDtoToEnvEntries(cloudEnv.secrets).length > 0) { warn( '.env.secrets is missing locally. Run `ensemble pull` to restore secrets from cloud. Secrets env push skipped.' ); @@ -149,17 +155,6 @@ export async function readProjectEnvFiles(projectRoot: string): Promise 0; -} - -export function cloudHasSecrets(cloudSecrets: SecretDTO | undefined): boolean { - return secretsDtoToEnvEntries(cloudSecrets).length > 0; -} - function entriesEqual(a: EnvEntry[], b: EnvEntry[]): boolean { const mapA = new Map(a.map((e) => [e.key, e.value])); const mapB = new Map(b.map((e) => [e.key, e.value])); @@ -275,10 +270,6 @@ export function buildEnvPushDiff( }; } -export function buildConfigDtoForReleaseSnapshot(entries: EnvEntry[]): ConfigDTO | undefined { - return buildConfigDtoFromEntries(entries); -} - export interface EnvPullChanges { assetFileNames: string[]; configMatch: boolean; @@ -316,8 +307,6 @@ export function computeEnvPullChanges( export interface EnvPushState { localEnv: LocalEnvFiles; diff: EnvPushDiff; - configChanged: boolean; - secretsChanged: boolean; pushConfigDto?: ConfigDTO; pushSecretsDto?: SecretDTO; } @@ -337,14 +326,7 @@ export async function prepareEnvPushState(params: { ? mergeConfigDtoForPush(diff.local.config, params.cloudEnv.config, params.assetFileNames) : diff.local.config; - return { - localEnv, - diff, - configChanged: diff.configChanged, - secretsChanged: diff.secretsChanged, - pushConfigDto, - pushSecretsDto: diff.local.secrets, - }; + return { localEnv, diff, pushConfigDto, pushSecretsDto: diff.local.secrets }; } /** restores `.env.config` from a release snapshot; secrets are never included in releases */ diff --git a/tests/cloud/firestoreClient.test.ts b/tests/cloud/firestoreClient.test.ts index d980f38..8bbf9eb 100644 --- a/tests/cloud/firestoreClient.test.ts +++ b/tests/cloud/firestoreClient.test.ts @@ -316,12 +316,24 @@ describe('fetchCloudApp', () => { expect(result.theme?.content).toBe('random theme'); }); - it('fetches appConfig and secrets artifacts into config and secrets', async () => { - const appDoc = { - name: 'projects/p/databases/(default)/documents/apps/app-1', + it('fetches appConfig and secrets into config and secrets', async () => { + const appDoc = { name: 'projects/p/databases/(default)/documents/apps/app-1' }; + const fetchForArtifacts = (artifacts: unknown[]) => async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes('/documents/apps/app-1') && !urlStr.includes('/artifacts')) { + return new Response(JSON.stringify(appDoc), { status: 200 }); + } + if (urlStr.includes('/artifacts')) { + return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); + } + if (urlStr.includes('internal_artifacts')) { + return new Response(JSON.stringify({ documents: [] }), { status: 200 }); + } + return new Response('Not found', { status: 404 }); }; - const artifacts = [ + globalThis.fetch = fetchForArtifacts([ { name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/appConfig', fields: { @@ -340,34 +352,12 @@ describe('fetchCloudApp', () => { content: { stringValue: JSON.stringify({ secrets: { S1: 'secret-value' } }) }, }, }, - ]; - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes('/documents/apps/app-1') && !urlStr.includes('/artifacts')) { - return new Response(JSON.stringify(appDoc), { status: 200 }); - } - if (urlStr.includes('/artifacts')) { - return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); - } - if (urlStr.includes('internal_artifacts')) { - return new Response(JSON.stringify({ documents: [] }), { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - const result = await fetchCloudApp('app-1', 'token'); - expect(result.config?.envVariables?.API_URL).toBe('https://api.example.com'); - expect(result.secrets?.secrets).toEqual({ S1: 'secret-value' }); - }); - - it('prefers native Firestore envVariables map over JSON content for config', async () => { - const appDoc = { - name: 'projects/p/databases/(default)/documents/apps/app-1', - }; + ]); + const withContent = await fetchCloudApp('app-1', 'token'); + expect(withContent.config?.envVariables?.API_URL).toBe('https://api.example.com'); + expect(withContent.secrets?.secrets).toEqual({ S1: 'secret-value' }); - const artifacts = [ + globalThis.fetch = fetchForArtifacts([ { name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/appConfig', fields: { @@ -377,33 +367,13 @@ describe('fetchCloudApp', () => { stringValue: JSON.stringify({ envVariables: { E1: 'from-content' } }), }, envVariables: { - mapValue: { - fields: { - E1: { stringValue: 'from-map' }, - }, - }, + mapValue: { fields: { E1: { stringValue: 'from-map' } } }, }, }, }, - ]; - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes('/documents/apps/app-1') && !urlStr.includes('/artifacts')) { - return new Response(JSON.stringify(appDoc), { status: 200 }); - } - if (urlStr.includes('/artifacts')) { - return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); - } - if (urlStr.includes('internal_artifacts')) { - return new Response(JSON.stringify({ documents: [] }), { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - const result = await fetchCloudApp('app-1', 'token'); - expect(result.config?.envVariables?.E1).toBe('from-map'); + ]); + const withMap = await fetchCloudApp('app-1', 'token'); + expect(withMap.config?.envVariables?.E1).toBe('from-map'); }); }); @@ -415,14 +385,14 @@ describe('submitEnvDocumentsPush', () => { vi.restoreAllMocks(); }); - it('patches content, envVariables map, and updatedAt for appConfig', async () => { - let capturedPatch: { url: string; body: string } | null = null; + it('patches content, map fields, and updatedAt for env documents', async () => { + const patches: Array<{ url: string; body: string }> = []; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const urlStr = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes('/artifacts/appConfig') && init?.method === 'PATCH') { - capturedPatch = { url: urlStr, body: (init.body as string) ?? '' }; + if (urlStr.includes('/artifacts/') && init?.method === 'PATCH') { + patches.push({ url: urlStr, body: (init.body as string) ?? '' }); return new Response('{}', { status: 200 }); } return new Response('Not found', { status: 404 }); @@ -430,52 +400,31 @@ describe('submitEnvDocumentsPush', () => { await submitEnvDocumentsPush('app-1', 'token', { config: { envVariables: { E1: 'EV11', assets: 'https://cdn.example.com/' } }, + secrets: { secrets: { S1: 'SK1', S2: 'SK22' } }, }); - expect(capturedPatch).not.toBeNull(); - expect(capturedPatch!.url).toContain('updateMask.fieldPaths=content'); - expect(capturedPatch!.url).toContain('updateMask.fieldPaths=envVariables'); - expect(capturedPatch!.url).toContain('updateMask.fieldPaths=updatedAt'); - const body = JSON.parse(capturedPatch!.body) as { + const configPatch = patches.find((patch) => patch.url.includes('/artifacts/appConfig')); + const secretsPatch = patches.find((patch) => patch.url.includes('/artifacts/secrets')); + expect(configPatch?.url).toContain('updateMask.fieldPaths=envVariables'); + expect(secretsPatch?.url).toContain('updateMask.fieldPaths=secrets'); + + const configBody = JSON.parse(configPatch!.body) as { fields: { content?: { stringValue?: string }; envVariables?: { mapValue?: { fields?: Record } }; }; }; - expect(JSON.parse(body.fields.content?.stringValue ?? '{}')).toEqual({ + expect(JSON.parse(configBody.fields.content?.stringValue ?? '{}')).toEqual({ envVariables: { E1: 'EV11', assets: 'https://cdn.example.com/' }, }); - expect(body.fields.envVariables?.mapValue?.fields?.E1?.stringValue).toBe('EV11'); - expect(body.fields.envVariables?.mapValue?.fields?.assets?.stringValue).toBe( - 'https://cdn.example.com/' - ); - }); - - it('patches content, secrets map, and updatedAt for secrets', async () => { - let capturedPatch: { url: string; body: string } | null = null; - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes('/artifacts/secrets') && init?.method === 'PATCH') { - capturedPatch = { url: urlStr, body: (init.body as string) ?? '' }; - return new Response('{}', { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - await submitEnvDocumentsPush('app-1', 'token', { - secrets: { secrets: { S1: 'SK1', S2: 'SK22' } }, - }); + expect(configBody.fields.envVariables?.mapValue?.fields?.E1?.stringValue).toBe('EV11'); - expect(capturedPatch).not.toBeNull(); - expect(capturedPatch!.url).toContain('updateMask.fieldPaths=secrets'); - const body = JSON.parse(capturedPatch!.body) as { + const secretsBody = JSON.parse(secretsPatch!.body) as { fields: { secrets?: { mapValue?: { fields?: Record } }; }; }; - expect(body.fields.secrets?.mapValue?.fields?.S2?.stringValue).toBe('SK22'); + expect(secretsBody.fields.secrets?.mapValue?.fields?.S2?.stringValue).toBe('SK22'); }); }); diff --git a/tests/commands/pushPull.test.ts b/tests/commands/pushPull.test.ts index a1d8034..d4fa01f 100644 --- a/tests/commands/pushPull.test.ts +++ b/tests/commands/pushPull.test.ts @@ -992,89 +992,51 @@ describe('push/pull integration (commands)', () => { errorSpy.mockRestore(); }); - it('pull writes cloud config and secrets to .env.config and .env.secrets', async () => { - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, - config: { envVariables: { API_URL: 'https://api.example.com' } }, - secrets: { secrets: { S1: 'secret-value' } }, - }); - - await pullCommand({ verbose: false, yes: true }); - - const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); - const envSecrets = await fs.readFile(path.join(projectRoot, '.env.secrets'), 'utf8'); - expect(envConfig).toContain('API_URL=https://api.example.com'); - expect(envSecrets).toContain('S1=secret-value'); - }); - - it('push uploads local env file changes to cloud', async () => { + it('push uploads local env changes via submitEnvDocumentsPush', async () => { await fs.writeFile( path.join(projectRoot, '.env.config'), 'API_URL=https://local.example.com\n', 'utf8' ); await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=local-secret\n', 'utf8'); - - const resolveAppContextMock = resolveAppContext as unknown as ReturnType; - resolveAppContextMock.mockResolvedValueOnce({ + (resolveAppContext as ReturnType).mockResolvedValueOnce({ projectRoot, config: { default: 'dev', apps: { - dev: { - appId: 'app1', - name: 'App', - appHome: undefined, - options: appOptionsRef.value, - }, + dev: { appId: 'app1', name: 'App', appHome: undefined, options: appOptionsRef.value }, }, }, appKey: 'dev', appId: 'app1', }); - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ id: 'app1', name: 'App', - screens: [] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, + screens: [], + widgets: [], + scripts: [], + translations: [], config: { envVariables: { API_URL: 'https://cloud.example.com' } }, secrets: { secrets: { S1: 'cloud-secret' } }, }); - await pushCommand({ verbose: true, yes: true }); - - const diffRaw = await fs.readFile(path.join(projectRoot, 'ensemble-diff.json'), 'utf8'); - const diff = JSON.parse(diffRaw) as { - env?: { - configChanged?: boolean; - local?: { config?: { envVariables?: Record } }; - }; - }; - expect(diff.env?.configChanged).toBe(true); - expect(diff.env?.local?.config?.envVariables?.API_URL).toBe('https://local.example.com'); + await pushCommand({ yes: true }); - const { submitEnvDocumentsPush } = cloudModuleMock as { - submitEnvDocumentsPush: ReturnType; - }; + const { submitEnvDocumentsPush } = cloudModuleMock; expect(submitEnvDocumentsPush).toHaveBeenCalledTimes(1); - const [, , payload] = submitEnvDocumentsPush.mock.calls[0] as [ - string, - string, - { - config?: { envVariables?: Record }; - secrets?: { secrets?: Record }; - }, - ]; + const rawCall = submitEnvDocumentsPush.mock.calls[0]; + expect(rawCall).toBeDefined(); + const payload = ( + rawCall as unknown as [ + string, + string, + { + config?: { envVariables?: Record }; + secrets?: { secrets?: Record }; + }, + ] + )[2]; expect(payload.config?.envVariables?.API_URL).toBe('https://local.example.com'); expect(payload.secrets?.secrets?.S1).toBe('local-secret'); }); diff --git a/tests/commands/release.test.ts b/tests/commands/release.test.ts index 2d38955..d375671 100644 --- a/tests/commands/release.test.ts +++ b/tests/commands/release.test.ts @@ -92,6 +92,7 @@ import { releaseUseCommand, } from '../../src/commands/release.js'; import type { CloudApp } from '../../src/cloud/firestoreClient.js'; +import { EnsembleDocumentType } from '../../src/core/dto.js'; async function writeEnvConfig(projectRoot: string, lines: string[]): Promise { await fs.writeFile(path.join(projectRoot, '.env.config'), `${lines.join('\n')}\n`, 'utf8'); @@ -163,17 +164,7 @@ describe('release commands', () => { vi.clearAllMocks(); }); - it('release create builds snapshot from local files and calls createVersion', async () => { - await releaseCreateCommand({ message: 'My release', yes: true }); - - // We don't assert createVersionMock directly here (module wiring in ESM tests), - // but we do verify the happy-path success message. - expect(uiSuccessMock).toHaveBeenCalledWith( - 'Release saved. Run "ensemble release use" to use it.' - ); - }); - - it('release create stores full .env.config in snapshot without secrets or asset publicUrl', async () => { + it('release create stores env config in snapshot without secrets or asset publicUrl', async () => { const assetsDir = path.join(projectRoot, 'assets'); await fs.mkdir(assetsDir, { recursive: true }); await fs.writeFile(path.join(assetsDir, 'logo.png'), 'png-bytes', 'utf8'); @@ -181,76 +172,30 @@ describe('release commands', () => { await writeEnvConfig(projectRoot, [ 'assets=https://cdn.example.com/base/', 'logo_png=logo.png?token=abc', - 'Case1_Working_png=Case1_Working.png?token=def', 'E1=EV1', ]); await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=SK1\n', 'utf8'); - await releaseCreateCommand({ message: 'complete env snapshot', yes: true }); + await releaseCreateCommand({ message: 'env snapshot', yes: true }); + expect(uiSuccessMock).toHaveBeenCalledWith( + 'Release saved. Run "ensemble release use" to use it.' + ); const snapshot = snapshotFromUploadMock(); expect(snapshot.config?.envVariables).toEqual({ assets: 'https://cdn.example.com/base/', logo_png: 'logo.png?token=abc', - Case1_Working_png: 'Case1_Working.png?token=def', E1: 'EV1', }); expect(snapshot.secrets).toBeUndefined(); - - const assetsByFile = new Map((snapshot.assets ?? []).map((asset) => [asset.fileName, asset])); - expect(assetsByFile.get('logo.png')?.publicUrl).toBeUndefined(); - expect(assetsByFile.get('logo.png')?.copyText).toBeUndefined(); - expect(assetsByFile.get('Case1_Working.png')?.publicUrl).toBeUndefined(); - expect(assetsByFile.get('Case1_Working.png')?.copyText).toBeUndefined(); - }); - - it('release create leaves assets without publicUrl when .env.config omits per-asset keys', async () => { - const assetsDir = path.join(projectRoot, 'assets'); - await fs.mkdir(assetsDir, { recursive: true }); - await fs.writeFile(path.join(assetsDir, 'Case1_Working.png'), 'png-bytes', 'utf8'); - await writeEnvConfig(projectRoot, ['assets=https://cdn.example.com/base/', 'E1=EV1']); - - await releaseCreateCommand({ message: 'partial env snapshot', yes: true }); - - const snapshot = snapshotFromUploadMock(); - expect(snapshot.config?.envVariables).toEqual({ - assets: 'https://cdn.example.com/base/', - E1: 'EV1', - }); expect(snapshot.config?.envVariables?.Case1_Working_png).toBeUndefined(); - - const asset = (snapshot.assets ?? []).find((item) => item.fileName === 'Case1_Working.png'); - expect(asset).toBeDefined(); - expect(asset?.publicUrl).toBeUndefined(); - expect(asset?.copyText).toBeUndefined(); - }); - - it('release use ignores secrets in snapshot and leaves local .env.secrets unchanged', async () => { - downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( - JSON.stringify({ - id: 'app1', - name: 'App', - screens: [], - config: { envVariables: { E1: 'EV1' } }, - secrets: { secrets: { S1: 'SNAPSHOT-SECRET' } }, - } satisfies CloudApp) - ); - await writeEnvConfig(projectRoot, ['E1=EV-WRONG']); - await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=LOCAL-SECRET\n', 'utf8'); - - Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); - Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); - - await releaseUseCommand({ hash: 'hash-1' }); - - const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); - const envSecrets = await fs.readFile(path.join(projectRoot, '.env.secrets'), 'utf8'); - expect(envConfig).toContain('E1=EV1'); - expect(envSecrets).toContain('S1=LOCAL-SECRET'); - expect(envSecrets).not.toContain('SNAPSHOT-SECRET'); + for (const asset of snapshot.assets ?? []) { + expect(asset.publicUrl).toBeUndefined(); + expect(asset.copyText).toBeUndefined(); + } }); - it('release use restores partial env when snapshot config omits per-asset keys', async () => { + it('release use restores snapshot config and never touches secrets', async () => { downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( JSON.stringify({ id: 'app1', @@ -262,15 +207,11 @@ describe('release commands', () => { name: 'Case1_Working.png', fileName: 'Case1_Working.png', content: '', - type: 'asset', + type: EnsembleDocumentType.Asset, }, ], - config: { - envVariables: { - assets: 'https://cdn.example.com/base/', - E1: 'EV1', - }, - }, + config: { envVariables: { assets: 'https://cdn.example.com/base/', E1: 'EV1' } }, + secrets: { secrets: { S1: 'SNAPSHOT-SECRET' } }, } satisfies CloudApp) ); await writeEnvConfig(projectRoot, [ @@ -278,6 +219,7 @@ describe('release commands', () => { 'Case1_Working_png=Case1_Working.png?token=old', 'E1=EV-WRONG', ]); + await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=LOCAL-SECRET\n', 'utf8'); Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); @@ -285,9 +227,12 @@ describe('release commands', () => { await releaseUseCommand({ hash: 'hash-1' }); const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); + const envSecrets = await fs.readFile(path.join(projectRoot, '.env.secrets'), 'utf8'); expect(envConfig).toContain('assets=https://cdn.example.com/base/'); expect(envConfig).toContain('E1=EV1'); expect(envConfig).not.toContain('Case1_Working_png='); + expect(envSecrets).toContain('S1=LOCAL-SECRET'); + expect(envSecrets).not.toContain('SNAPSHOT-SECRET'); }); it('release list prints heading and lines when versions exist', async () => { diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 3f5836d..5f6ab88 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -7,21 +7,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { applyCloudEnvToFs, applyReleaseConfigToFs, - buildConfigDtoForReleaseSnapshot, - buildConfigDtoFromEnvConfigFile, buildEnvPushDiff, - buildSecretsDtoFromEnvSecretsFile, computeEnvPullChanges, - configDtoToEnvEntries, - envConfigEntriesMatchCloud, - envSecretsEntriesMatchCloud, mergeConfigDtoForPush, prepareEnvPushState, - readProjectEnvFiles, - secretsDtoToEnvEntries, warnIfMissingEnvFilesForPush, + type CloudEnvState, + type LocalEnvFiles, } from '../../src/core/envSync.js'; -import type { ConfigDTO, SecretDTO } from '../../src/core/dto.js'; describe('envSync', () => { let tmpDir: string; @@ -34,151 +27,99 @@ describe('envSync', () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); - it('maps cloud config envVariables to .env.config entries', () => { - const config: ConfigDTO = { envVariables: { API_URL: 'https://api.example.com' } }; - expect(configDtoToEnvEntries(config)).toEqual([ - { key: 'API_URL', value: 'https://api.example.com' }, - ]); - }); - - it('maps cloud secrets to .env.secrets entries', () => { - const secrets: SecretDTO = { secrets: { S1: 'secret-value' } }; - expect(secretsDtoToEnvEntries(secrets)).toEqual([{ key: 'S1', value: 'secret-value' }]); - }); - - it('writes cloud env vars and secrets to local env files on pull', async () => { - await applyCloudEnvToFs(tmpDir, { - config: { envVariables: { API_URL: 'https://api.example.com' } }, - secrets: { secrets: { S1: 'secret-value' } }, - }); - - const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); - const envSecrets = await fs.readFile(path.join(tmpDir, '.env.secrets'), 'utf8'); - expect(envConfig).toContain('API_URL=https://api.example.com'); - expect(envSecrets).toContain('S1=secret-value'); - }); - - it('preserves asset keys in .env.config when applying cloud config env vars', async () => { + it('applyCloudEnvToFs syncs cloud env, preserves asset keys, and drops removed keys', async () => { await fs.writeFile( path.join(tmpDir, '.env.config'), - 'assets=https://cdn.example.com/\nlogo_png=logo.png\n', + 'assets=https://cdn.example.com/\nlogo_png=logo.png\nE1=EV1\nE2=EV2\n', 'utf8' ); await applyCloudEnvToFs( tmpDir, { - config: { envVariables: { API_URL: 'https://api.example.com' } }, + config: { envVariables: { API_URL: 'https://api.example.com', E1: 'EV1' } }, + secrets: { secrets: { S1: 'secret-value' } }, }, ['logo.png'] ); const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + const envSecrets = await fs.readFile(path.join(tmpDir, '.env.secrets'), 'utf8'); expect(envConfig).toContain('assets=https://cdn.example.com/'); expect(envConfig).toContain('logo_png=logo.png'); expect(envConfig).toContain('API_URL=https://api.example.com'); + expect(envConfig).toContain('E1=EV1'); + expect(envConfig).not.toContain('E2='); + expect(envSecrets).toContain('S1=secret-value'); }); - it('reads local env files excluding asset keys from config payload', async () => { - await fs.writeFile( - path.join(tmpDir, '.env.config'), - [ - 'assets=https://cdn.example.com/', - 'logo_png=logo.png', - 'API_URL=https://local.example.com', - ].join('\n') + '\n', - 'utf8' - ); - await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=local-secret\n', 'utf8'); - - const local = await readProjectEnvFiles(tmpDir); - expect(buildConfigDtoFromEnvConfigFile(local.envConfig, ['logo.png'])).toEqual({ - envVariables: { API_URL: 'https://local.example.com' }, - }); - expect(buildSecretsDtoFromEnvSecretsFile(local.envSecrets)).toEqual({ - secrets: { S1: 'local-secret' }, - }); - }); - - it('detects when local env files differ from cloud', async () => { - await fs.writeFile( - path.join(tmpDir, '.env.config'), - 'API_URL=https://local.example.com\n', - 'utf8' - ); - await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=local-secret\n', 'utf8'); - - const local = await readProjectEnvFiles(tmpDir); - expect( - envConfigEntriesMatchCloud(local.envConfig, { - envVariables: { API_URL: 'https://cloud.example.com' }, - }) - ).toBe(false); - expect(envSecretsEntriesMatchCloud(local.envSecrets, { secrets: { S1: 'cloud-secret' } })).toBe( - false - ); - }); - - it('builds env push diff with local and cloud values', async () => { - const local = { - envConfig: [{ key: 'API_URL', value: 'https://local.example.com' }], - envSecrets: [{ key: 'S1', value: 'local-secret' }], - }; - const diff = buildEnvPushDiff( - local, - { - config: { envVariables: { API_URL: 'https://cloud.example.com' } }, - secrets: { secrets: { S1: 'cloud-secret' } }, + it.each<{ + name: string; + local: LocalEnvFiles; + cloud: CloudEnvState; + assets: string[]; + configChanged: boolean; + secretsChanged: boolean; + cloudConfig?: Record; + localConfig?: Record; + }>([ + { + name: 'detects config and secrets changes', + local: { + envConfig: [{ key: 'E1', value: 'local' }], + envSecrets: [{ key: 'S1', value: 'local' }], + envConfigPresent: true, + envSecretsPresent: true, }, - [] - ); - expect(diff.configChanged).toBe(true); - expect(diff.secretsChanged).toBe(true); - expect(diff.local.config?.envVariables?.API_URL).toBe('https://local.example.com'); - expect(diff.cloud.config?.envVariables?.API_URL).toBe('https://cloud.example.com'); - }); - - it('compares asset env key values when cloud config includes them', () => { - const local = { - envConfig: [ - { key: 'assets', value: 'https://cdn.example.com/' }, - { key: 'logo_png', value: 'logo.png' }, - { key: 'E1', value: 'EV1' }, - { key: 'E2', value: 'EV2' }, - ], - envSecrets: [], - }; - const cloudConfig: ConfigDTO = { - envVariables: { - assets: 'https://cdn.example.com/', - logo_png: 'logo.png?token=abc', - E1: 'EV1', - E2: 'EV2', + cloud: { + config: { envVariables: { E1: 'cloud' } }, + secrets: { secrets: { S1: 'cloud' } }, }, - }; - expect(envConfigEntriesMatchCloud(local.envConfig, cloudConfig, ['logo.png'])).toBe(false); - - const matchingLocal = { - envConfig: [ - { key: 'assets', value: 'https://cdn.example.com/' }, - { key: 'logo_png', value: 'logo.png?token=abc' }, - { key: 'E1', value: 'EV1' }, - { key: 'E2', value: 'EV2' }, - ], - envSecrets: [], - }; - expect(envConfigEntriesMatchCloud(matchingLocal.envConfig, cloudConfig, ['logo.png'])).toBe( - true - ); - }); - - it('strips asset keys from cloud side of env push diff', () => { - const diff = buildEnvPushDiff( - { + assets: [], + configChanged: true, + secretsChanged: true, + }, + { + name: 'omits snapshots when in sync', + local: { + envConfig: [{ key: 'E1', value: 'EV1' }], + envSecrets: [{ key: 'S1', value: 'SK1' }], + envConfigPresent: true, + envSecretsPresent: true, + }, + cloud: { + config: { envVariables: { E1: 'EV1' } }, + secrets: { secrets: { S1: 'SK1' } }, + }, + assets: [], + configChanged: false, + secretsChanged: false, + }, + { + name: 'skips push when env files are missing', + local: { + envConfig: [], + envSecrets: [], + envConfigPresent: false, + envSecretsPresent: false, + }, + cloud: { + config: { envVariables: { E1: 'EV1' } }, + secrets: { secrets: { S1: 'SK1' } }, + }, + assets: [], + configChanged: false, + secretsChanged: false, + }, + { + name: 'strips asset keys from cloud diff', + local: { envConfig: [{ key: 'E1', value: 'EV11' }], envSecrets: [], + envConfigPresent: true, + envSecretsPresent: true, }, - { + cloud: { config: { envVariables: { assets: 'https://cdn.example.com/', @@ -188,159 +129,120 @@ describe('envSync', () => { }, }, }, - ['logo.png'] - ); - expect(diff.configChanged).toBe(true); - expect(diff.cloud.config?.envVariables).toEqual({ E1: 'EV1', E2: 'EV2' }); - expect(diff.local.config?.envVariables).toEqual({ E1: 'EV11' }); - }); - - it('removes deleted non-asset keys from .env.config when cloud no longer has them', async () => { - await fs.writeFile( - path.join(tmpDir, '.env.config'), - 'assets=https://cdn.example.com/\nlogo_png=logo.png\nE1=EV1\nE2=EV2\n', - 'utf8' - ); - - await applyCloudEnvToFs(tmpDir, { config: { envVariables: { E1: 'EV1' } } }, ['logo.png']); - - const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); - expect(envConfig).toContain('E1=EV1'); - expect(envConfig).not.toContain('E2='); - expect(envConfig).toContain('assets=https://cdn.example.com/'); - expect(envConfig).toContain('logo_png=logo.png'); - }); - - it('merges local env vars with cloud asset keys for push payload', () => { - const merged = mergeConfigDtoForPush( - { envVariables: { E1: 'EV11', E2: 'EV2' } }, - { - envVariables: { - assets: 'https://cdn.example.com/', - logo_png: 'logo.png?token=abc', - E1: 'EV1', + assets: ['logo.png'], + configChanged: true, + secretsChanged: false, + cloudConfig: { E1: 'EV1', E2: 'EV2' }, + localConfig: { E1: 'EV11' }, + }, + ])( + '$name', + ({ local, cloud, assets, configChanged, secretsChanged, cloudConfig, localConfig }) => { + const diff = buildEnvPushDiff(local, cloud, assets); + expect(diff.configChanged).toBe(configChanged); + expect(diff.secretsChanged).toBe(secretsChanged); + if (!configChanged && !secretsChanged) { + expect(diff.local).toEqual({}); + expect(diff.cloud).toEqual({}); + return; + } + if (cloudConfig) expect(diff.cloud.config?.envVariables).toEqual(cloudConfig); + if (localConfig) expect(diff.local.config?.envVariables).toEqual(localConfig); + } + ); + + it('mergeConfigDtoForPush preserves cloud asset keys and drops removed non-asset keys', () => { + expect( + mergeConfigDtoForPush( + { envVariables: { E1: 'EV11', E2: 'EV2' } }, + { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?token=abc', + E1: 'EV1', + }, }, - }, - ['logo.png'] - ); - expect(merged.envVariables).toEqual({ + ['logo.png'] + ).envVariables + ).toEqual({ assets: 'https://cdn.example.com/', logo_png: 'logo.png?token=abc', E1: 'EV11', E2: 'EV2', }); - }); - - it('drops removed non-asset keys from push payload', () => { - const merged = mergeConfigDtoForPush( - { envVariables: { E1: 'EV11' } }, - { - envVariables: { - assets: 'https://cdn.example.com/', - E1: 'EV1', - E2: 'EV2', - }, - }, - ['logo.png'] - ); - expect(merged.envVariables).toEqual({ - assets: 'https://cdn.example.com/', - E1: 'EV11', - }); - }); - - it('buildConfigDtoForReleaseSnapshot includes asset keys from .env.config', () => { - const dto = buildConfigDtoForReleaseSnapshot([ - { key: 'assets', value: 'https://cdn.example.com/' }, - { key: 'logo_png', value: 'logo.png?token=abc' }, - { key: 'E1', value: 'EV1' }, - ]); - expect(dto?.envVariables).toEqual({ - assets: 'https://cdn.example.com/', - logo_png: 'logo.png?token=abc', - E1: 'EV1', - }); - }); - - it('applyReleaseConfigToFs restores .env.config from snapshot config only', async () => { - await applyReleaseConfigToFs(tmpDir, { - envVariables: { - assets: 'https://cdn.example.com/', - logo_png: 'logo.png', - E1: 'EV1', - }, - }); - - const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); - expect(envConfig).toContain('assets=https://cdn.example.com/'); - expect(envConfig).toContain('logo_png=logo.png'); - expect(envConfig).toContain('E1=EV1'); - }); - it('detects missing asset env keys in .env.config when comparing pull state', () => { expect( - envConfigEntriesMatchCloud( - [ - { key: 'assets', value: 'https://cdn.example.com/' }, - { key: 'E1', value: 'EV111' }, - ], - { - envVariables: { - assets: 'https://cdn.example.com/', - Case1_Working_png: 'Case1_Working.png?alt=media&token=abc', - E1: 'EV111', - }, - }, - ['Case1_Working.png'] - ) - ).toBe(false); + mergeConfigDtoForPush( + { envVariables: { E1: 'EV11' } }, + { envVariables: { assets: 'https://cdn.example.com/', E1: 'EV1', E2: 'EV2' } }, + ['logo.png'] + ).envVariables + ).toEqual({ assets: 'https://cdn.example.com/', E1: 'EV11' }); }); - it('computeEnvPullChanges lists env files that differ from cloud', () => { + it('computeEnvPullChanges flags config mismatch including missing asset env keys', () => { const result = computeEnvPullChanges( { - envConfig: [{ key: 'E1', value: 'EV1' }], + envConfig: [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'E1', value: 'EV111' }, + ], envSecrets: [{ key: 'S1', value: 'SK1' }], envConfigPresent: true, envSecretsPresent: true, }, - { envVariables: { E1: 'EV2', assets: 'https://cdn.example.com/' } }, + { + envVariables: { + assets: 'https://cdn.example.com/', + Case1_Working_png: 'Case1_Working.png?alt=media&token=abc', + E1: 'EV111', + }, + }, { secrets: { S1: 'SK1' } }, - ['logo.png'], - [{ fileName: 'logo.png' }] + ['Case1_Working.png'], + [{ fileName: 'Case1_Working.png' }] ); expect(result.configMatch).toBe(false); expect(result.secretsMatch).toBe(true); - expect(result.match).toBe(false); expect(result.filesToUpdate).toEqual(['.env.config']); }); - it('prepareEnvPushState builds merged config for push', async () => { + it('prepareEnvPushState merges local config with cloud asset keys', async () => { await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=EV11\n', 'utf8'); const state = await prepareEnvPushState({ projectRoot: tmpDir, cloudEnv: { - config: { - envVariables: { - assets: 'https://cdn.example.com/', - E1: 'EV1', - }, - }, + config: { envVariables: { assets: 'https://cdn.example.com/', E1: 'EV1' } }, }, assetFileNames: [], warn: () => {}, }); - expect(state.configChanged).toBe(true); + expect(state.diff.configChanged).toBe(true); expect(state.pushConfigDto?.envVariables).toEqual({ assets: 'https://cdn.example.com/', E1: 'EV11', }); }); - it('warns when env files are missing locally but cloud has values', () => { + it('applyReleaseConfigToFs restores full snapshot config', async () => { + await applyReleaseConfigToFs(tmpDir, { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png', + E1: 'EV1', + }, + }); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + expect(envConfig).toContain('assets=https://cdn.example.com/'); + expect(envConfig).toContain('logo_png=logo.png'); + expect(envConfig).toContain('E1=EV1'); + }); + + it('warnIfMissingEnvFilesForPush warns when cloud has values but local files are missing', () => { const warnings: string[] = []; warnIfMissingEnvFilesForPush( { @@ -360,44 +262,4 @@ describe('envSync', () => { expect(warnings[0]).toContain('.env.config is missing'); expect(warnings[1]).toContain('.env.secrets is missing'); }); - - it('does not treat missing env files as empty local deletes on push', () => { - const diff = buildEnvPushDiff( - { - envConfig: [], - envSecrets: [], - envConfigPresent: false, - envSecretsPresent: false, - }, - { - config: { envVariables: { E1: 'EV1' } }, - secrets: { secrets: { S1: 'SK1' } }, - }, - [] - ); - expect(diff.configChanged).toBe(false); - expect(diff.secretsChanged).toBe(false); - expect(diff.local).toEqual({}); - expect(diff.cloud).toEqual({}); - }); - - it('omits local and cloud env snapshots when already in sync', () => { - const diff = buildEnvPushDiff( - { - envConfig: [{ key: 'E1', value: 'EV1' }], - envSecrets: [{ key: 'S1', value: 'SK1' }], - envConfigPresent: true, - envSecretsPresent: true, - }, - { - config: { envVariables: { E1: 'EV1' } }, - secrets: { secrets: { S1: 'SK1' } }, - }, - [] - ); - expect(diff.configChanged).toBe(false); - expect(diff.secretsChanged).toBe(false); - expect(diff.local).toEqual({}); - expect(diff.cloud).toEqual({}); - }); }); From f01bc0db71bd684f871c3265f4037b0e8af36089 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Tue, 16 Jun 2026 19:56:46 +0500 Subject: [PATCH 05/10] fix(assets): archive removed assets on push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also drop stale asset env keys from appConfig so pull doesn’t resurrect deleted asset vars --- src/cloud/firestoreClient.ts | 82 +++++++++++++++++++++++++++++++++++ src/commands/push.ts | 4 +- src/core/bundleDiff.ts | 64 +++++++++++++++++++++++++-- src/core/envSync.ts | 57 ++++++++++++++++++++---- src/core/sync.ts | 5 ++- tests/core/bundleDiff.test.ts | 42 ++++++++++++++++++ tests/core/envSync.test.ts | 33 +++++++++----- 7 files changed, 260 insertions(+), 27 deletions(-) diff --git a/src/cloud/firestoreClient.ts b/src/cloud/firestoreClient.ts index 20282f0..72d309b 100644 --- a/src/cloud/firestoreClient.ts +++ b/src/cloud/firestoreClient.ts @@ -294,6 +294,7 @@ interface PushPayloadShape { actions?: YamlArtifactPushOperation[]; translations?: YamlArtifactPushOperation[]; theme?: YamlArtifactPushOperation; + assets?: YamlArtifactPushOperation[]; } type CreateYamlOp = Extract; @@ -321,6 +322,86 @@ function getFirestoreConcurrency(): number { return Math.floor(parsed); } +async function applyAssetArchiveOperations( + appId: string, + idToken: string, + project: string, + ops: YamlArtifactPushOperation[] | undefined, + options?: FirestoreClientOptions +): Promise { + if (!ops || ops.length === 0) return; + + const baseCollectionUrl = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}/artifacts`; + const concurrency = getFirestoreConcurrency(); + + await processWithConcurrency( + ops, + async (op) => { + if (op.operation !== 'update') return; + + const docId = op.id; + const docUrl = `${baseCollectionUrl}/${encodeURIComponent(docId)}`; + const historyFields = encodeHistoryFields(op.history); + const historyUrl = `${docUrl}/history`; + + logDebug(options, { + kind: 'push_operation', + appId, + operation: 'update', + artifactKind: 'screens', + documentId: docId, + }); + logDebug(options, { + kind: 'request', + method: 'POST', + url: historyUrl, + context: 'submitCliPush/writeAssetHistory', + }); + const historyRes = await fetch(historyUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields: historyFields }), + }); + if (!historyRes.ok) { + throw await toFirestoreError( + `write history for asset "${op.history.name}"`, + historyRes, + options + ); + } + + const { fields: updateFields, fieldPaths } = encodeUpdateFields('screens', op.updates); + if (fieldPaths.length === 0) return; + + const params = fieldPaths + .map((path) => `updateMask.fieldPaths=${encodeURIComponent(path)}`) + .join('&'); + const patchUrl = `${docUrl}?${params}`; + logDebug(options, { + kind: 'request', + method: 'PATCH', + url: patchUrl, + context: 'submitCliPush/patchAsset', + }); + const patchRes = await fetch(patchUrl, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields: updateFields }), + }); + if (!patchRes.ok) { + throw await toFirestoreError(`archive asset "${op.history.name}"`, patchRes, options); + } + }, + concurrency + ); +} + async function applyYamlOperationsForKind( kind: 'screens' | 'widgets' | 'scripts' | 'actions' | 'translations' | 'theme', appId: string, @@ -478,6 +559,7 @@ export async function submitCliPush( if (p.theme) { await applyYamlOperationsForKind('theme', appId, idToken, project, [p.theme], options); } + await applyAssetArchiveOperations(appId, idToken, project, p.assets, options); const names = extras?.assetFileNames?.filter((n) => n.trim() !== '') ?? []; if (names.length === 0 || !extras?.projectRoot) { diff --git a/src/commands/push.ts b/src/commands/push.ts index 82bf4fd..c0175cc 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -368,10 +368,12 @@ export async function pushCommand(options: PushOptions = {}): Promise { const assetsToUpload = plan.diff.assets.new .map((item) => (item as { fileName?: string }).fileName) .filter((fn): fn is string => typeof fn === 'string' && fn.length > 0); + const assetsToArchive = plan.diff.assets.changed.filter((item) => item.isArchived === true); if ( yamlChangeTotal === 0 && assetsToUpload.length === 0 && + assetsToArchive.length === 0 && !envConfigChanged && !envSecretsChanged ) { @@ -498,7 +500,7 @@ export async function pushCommand(options: PushOptions = {}): Promise { } try { - if (yamlChangeTotal > 0 || assetsToUpload.length > 0) { + if (yamlChangeTotal > 0 || assetsToUpload.length > 0 || assetsToArchive.length > 0) { const { assetsUploaded } = await withSpinner('Pushing changes to cloud...', () => submitCliPush(appId, idToken, pushPayload, firestoreOptions, { projectRoot: root, diff --git a/src/core/bundleDiff.ts b/src/core/bundleDiff.ts index fa52152..42328a3 100644 --- a/src/core/bundleDiff.ts +++ b/src/core/bundleDiff.ts @@ -129,9 +129,10 @@ function diffAssets( localAssets: AssetDTO[] | undefined, cloudAssets: AssetDTO[] | undefined ): { changed: ArtifactWithContent[]; new: ArtifactWithContent[] } { - const cloudActiveByFile = new Set( - (cloudAssets ?? []).filter((a) => a.isArchived !== true).map((a) => a.fileName) - ); + const localByFile = new Set((localAssets ?? []).map((asset) => asset.fileName)); + const cloudActive = (cloudAssets ?? []).filter((asset) => asset.isArchived !== true); + const cloudActiveByFile = new Map(cloudActive.map((asset) => [asset.fileName, asset])); + const newItems: ArtifactWithContent[] = []; for (const local of localAssets ?? []) { if (!cloudActiveByFile.has(local.fileName)) { @@ -143,7 +144,21 @@ function diffAssets( } as ArtifactWithContent); } } - return { changed: [], new: newItems }; + + const changedItems: ArtifactWithContent[] = []; + for (const cloud of cloudActive) { + if (!localByFile.has(cloud.fileName)) { + changedItems.push({ + id: cloud.id, + name: cloud.name, + content: cloud.content ?? '', + fileName: cloud.fileName, + isArchived: true, + } as ArtifactWithContent); + } + } + + return { changed: changedItems, new: newItems }; } type ArtifactDisplay = ArtifactWithContent & { fileName?: string }; @@ -369,6 +384,39 @@ function buildYamlPushItems( return items; } +function buildAssetPushItems( + diff: { changed: ArtifactWithContent[] }, + cloudAssets: AssetDTO[] | undefined, + now: string, + updatedBy: { name: string; email?: string; id: string } +): YamlArtifactPushItem[] { + const cloudById = new Map((cloudAssets ?? []).map((asset) => [asset.id, asset])); + const items: YamlArtifactPushItem[] = []; + + for (const bundle of diff.changed) { + if (!bundle.isArchived) continue; + const cloud = cloudById.get(bundle.id); + if (!cloud) continue; + const cloudWithMeta = cloud as ArtifactWithContent & { + type?: string; + updatedAt?: string; + updatedBy?: object; + }; + items.push({ + operation: 'update', + id: cloud.id, + history: buildHistoryEntry(cloudWithMeta), + updates: { + isArchived: true, + updatedAt: now, + updatedBy, + }, + }); + } + + return items; +} + /** * Build push payload with history + partial updates for YAML artifacts. * - create: full document for new items @@ -575,6 +623,13 @@ export function buildPushPayload( } } + const assets = buildAssetPushItems( + diff.assets, + cloudApp.assets as AssetDTO[] | undefined, + now, + updatedBy + ); + return { id: bundle.id, name: bundle.name, @@ -585,5 +640,6 @@ export function buildPushPayload( ...(actions.length > 0 && { actions }), ...(translations.length > 0 && { translations }), ...(theme && { theme }), + ...(assets.length > 0 && { assets }), }; } diff --git a/src/core/envSync.ts b/src/core/envSync.ts index e3dbcfe..e5bd79f 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -98,18 +98,52 @@ export function buildConfigDtoFromEnvConfigFile( }); } +export function buildLocalAssetConfigFromEnvFile( + entries: EnvEntry[], + assetFileNames: string[] = [] +): ConfigDTO | undefined { + const assetKeys = collectAssetEnvKeys(assetFileNames); + const envVariables: Record = {}; + for (const entry of entries) { + if (assetKeys.has(entry.key)) { + envVariables[entry.key] = entry.value; + } + } + return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; +} + export function mergeConfigDtoForPush( localNonAssetConfig: ConfigDTO | undefined, cloudConfig: ConfigDTO | undefined, - assetFileNames: string[] = [] + assetFileNames: string[] = [], + localAssetConfig?: ConfigDTO ): ConfigDTO { - const { asset } = partitionConfigEnvVariables(cloudConfig, assetFileNames); - return { - envVariables: { - ...asset, - ...(localNonAssetConfig?.envVariables ?? {}), - }, - }; + const { asset: cloudAsset } = partitionConfigEnvVariables(cloudConfig, assetFileNames); + const localAsset = localAssetConfig?.envVariables ?? {}; + const envVariables: Record = {}; + for (const [key, value] of Object.entries(localNonAssetConfig?.envVariables ?? {})) { + if (typeof value === 'string') { + envVariables[key] = value; + } + } + + if (assetFileNames.length > 0) { + if (typeof localAsset.assets === 'string' && localAsset.assets.trim() !== '') { + envVariables.assets = localAsset.assets; + } else if (typeof cloudAsset.assets === 'string' && cloudAsset.assets.trim() !== '') { + envVariables.assets = cloudAsset.assets; + } + + for (const fileName of assetFileNames) { + const envKey = deriveAssetEnvKey(fileName); + const value = localAsset[envKey]; + if (typeof value === 'string') { + envVariables[envKey] = value; + } + } + } + + return { envVariables }; } function entriesToRecord(entries: EnvEntry[]): Record { @@ -323,7 +357,12 @@ export async function prepareEnvPushState(params: { const pushConfigDto = diff.configChanged && diff.local.config - ? mergeConfigDtoForPush(diff.local.config, params.cloudEnv.config, params.assetFileNames) + ? mergeConfigDtoForPush( + diff.local.config, + params.cloudEnv.config, + params.assetFileNames, + buildLocalAssetConfigFromEnvFile(localEnv.envConfig, params.assetFileNames) + ) : diff.local.config; return { localEnv, diff, pushConfigDto, pushSecretsDto: diff.local.secrets }; diff --git a/src/core/sync.ts b/src/core/sync.ts index 8b9f026..e6534ee 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -74,10 +74,11 @@ function computeKindCounts(items: BundleDiff['screens']): PushCounts { } function computeAssetCounts(items: BundleDiff['assets']): PushCounts { + const deleted = items.changed.filter((item) => item.isArchived === true).length; return { created: items.new.length, - updated: 0, - deleted: 0, + updated: items.changed.filter((item) => item.isArchived !== true).length, + deleted, }; } diff --git a/tests/core/bundleDiff.test.ts b/tests/core/bundleDiff.test.ts index da44d0e..65c577c 100644 --- a/tests/core/bundleDiff.test.ts +++ b/tests/core/bundleDiff.test.ts @@ -55,6 +55,26 @@ describe('computeBundleDiff', () => { expect(diff.assets.new[0]).toMatchObject({ fileName: 'new.png' }); }); + it('detects assets deleted locally as archive changes', () => { + const cloudApp = { + id: 'app1', + name: 'App', + assets: [asset('a1', 'logo.png', ''), asset('a2', 'old.png', '')], + }; + const localApp = { + id: 'app1', + name: 'App', + assets: [asset('asset:logo.png', 'logo.png', '')], + }; + const diff = computeBundleDiff(localApp, cloudApp, localApp); + expect(diff.assets.changed).toHaveLength(1); + expect(diff.assets.changed[0]).toMatchObject({ + id: 'a2', + fileName: 'old.png', + isArchived: true, + }); + }); + it('detects changed screens', () => { const cloud: ApplicationDTO = { id: 'app1', @@ -316,6 +336,28 @@ describe('buildPushPayload', () => { } }); + it('archives assets removed locally', () => { + const cloudApp: ApplicationDTO = { + id: 'app1', + name: 'App', + assets: [asset('a1', 'old.png', '')], + }; + const bundle: ApplicationDTO = { + id: 'app1', + name: 'App', + assets: [], + }; + const diff = computeBundleDiff(bundle, cloudApp, bundle); + const payload = buildPushPayload(bundle, diff, cloudApp, updatedBy); + expect(payload.assets).toHaveLength(1); + const archiveItem = payload.assets![0]; + expect(archiveItem.operation).toBe('update'); + if (archiveItem.operation === 'update') { + expect(archiveItem.id).toBe('a1'); + expect(archiveItem.updates.isArchived).toBe(true); + } + }); + it('includes createdAt/createdBy/updatedBy for new artifacts', () => { const cloudApp: ApplicationDTO = { id: 'app1', diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 5f6ab88..29ac431 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -151,7 +151,7 @@ describe('envSync', () => { } ); - it('mergeConfigDtoForPush preserves cloud asset keys and drops removed non-asset keys', () => { + it('mergeConfigDtoForPush uses local asset keys only and drops removed non-asset keys', () => { expect( mergeConfigDtoForPush( { envVariables: { E1: 'EV11', E2: 'EV2' } }, @@ -162,11 +162,17 @@ describe('envSync', () => { E1: 'EV1', }, }, - ['logo.png'] + ['logo.png'], + { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?local=abc', + }, + } ).envVariables ).toEqual({ assets: 'https://cdn.example.com/', - logo_png: 'logo.png?token=abc', + logo_png: 'logo.png?local=abc', E1: 'EV11', E2: 'EV2', }); @@ -174,10 +180,18 @@ describe('envSync', () => { expect( mergeConfigDtoForPush( { envVariables: { E1: 'EV11' } }, - { envVariables: { assets: 'https://cdn.example.com/', E1: 'EV1', E2: 'EV2' } }, - ['logo.png'] + { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?token=abc', + E1: 'EV1', + E2: 'EV2', + }, + }, + [], + undefined ).envVariables - ).toEqual({ assets: 'https://cdn.example.com/', E1: 'EV11' }); + ).toEqual({ E1: 'EV11' }); }); it('computeEnvPullChanges flags config mismatch including missing asset env keys', () => { @@ -208,7 +222,7 @@ describe('envSync', () => { expect(result.filesToUpdate).toEqual(['.env.config']); }); - it('prepareEnvPushState merges local config with cloud asset keys', async () => { + it('prepareEnvPushState omits cloud asset keys when no local asset files exist', async () => { await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=EV11\n', 'utf8'); const state = await prepareEnvPushState({ @@ -221,10 +235,7 @@ describe('envSync', () => { }); expect(state.diff.configChanged).toBe(true); - expect(state.pushConfigDto?.envVariables).toEqual({ - assets: 'https://cdn.example.com/', - E1: 'EV11', - }); + expect(state.pushConfigDto?.envVariables).toEqual({ E1: 'EV11' }); }); it('applyReleaseConfigToFs restores full snapshot config', async () => { From ac230635827287e2c9e861a3ab0f7273d3e39b55 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Tue, 16 Jun 2026 21:53:43 +0500 Subject: [PATCH 06/10] feat(env): implement pruning of stale asset env entries on push --- src/commands/push.ts | 13 +- src/core/bundleDiff.ts | 2 +- src/core/envSync.ts | 468 ++++++++++++++++++---------------- src/core/pullAssets.ts | 6 +- tests/core/bundleDiff.test.ts | 31 +++ tests/core/envSync.test.ts | 212 +++++++++++++-- 6 files changed, 487 insertions(+), 245 deletions(-) diff --git a/src/commands/push.ts b/src/commands/push.ts index c0175cc..5885362 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -23,6 +23,7 @@ import { writeVerboseJson } from '../core/debugFiles.js'; import { computePushPlan, type PushSummary, type PushCounts } from '../core/sync.js'; import { buildAndWriteManifest } from '../core/manifest.js'; import { prepareEnvPushState } from '../core/envSync.js'; +import { writeEnvFile } from '../core/envConfig.js'; import { ui } from '../core/ui.js'; export interface PushOptions { @@ -342,9 +343,15 @@ export async function pushCommand(options: PushOptions = {}): Promise { projectRoot: root, cloudEnv: { config: cloudApp.config, secrets: cloudApp.secrets }, assetFileNames, + cloudAssets: cloudApp.assets, warn: (message) => ui.warn(message), }); - const { diff: envPushDiff, pushConfigDto, pushSecretsDto: localSecretsDto } = envPush; + const { + diff: envPushDiff, + pushConfigDto, + pushSecretsDto: localSecretsDto, + pendingLocalEnvConfigWrite, + } = envPush; const envConfigChanged = envPushDiff.configChanged; const envSecretsChanged = envPushDiff.secretsChanged; @@ -500,6 +507,10 @@ export async function pushCommand(options: PushOptions = {}): Promise { } try { + if (pendingLocalEnvConfigWrite) { + await writeEnvFile(root, '.env.config', pendingLocalEnvConfigWrite); + } + if (yamlChangeTotal > 0 || assetsToUpload.length > 0 || assetsToArchive.length > 0) { const { assetsUploaded } = await withSpinner('Pushing changes to cloud...', () => submitCliPush(appId, idToken, pushPayload, firestoreOptions, { diff --git a/src/core/bundleDiff.ts b/src/core/bundleDiff.ts index 42328a3..19ac3f2 100644 --- a/src/core/bundleDiff.ts +++ b/src/core/bundleDiff.ts @@ -273,7 +273,7 @@ export function computeBundleDiff( ); const assets = diffAssets( - localAppForAssets?.assets ?? bundle.assets, + localAppForAssets?.assets ?? [], cloudApp.assets as AssetDTO[] | undefined ); diff --git a/src/core/envSync.ts b/src/core/envSync.ts index e5bd79f..4f49c72 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -1,5 +1,7 @@ +import path from 'node:path'; + import type { ConfigDTO, SecretDTO } from './dto.js'; -import { deriveAssetEnvKey } from './pullAssets.js'; +import { deriveAssetEnvKey, resolveAssetEnvKey } from './pullAssets.js'; import { envFileExists, readEnvFile, @@ -16,166 +18,217 @@ export interface CloudEnvState { export interface LocalEnvFiles { envConfig: EnvEntry[]; envSecrets: EnvEntry[]; - /** false when the file does not exist (distinct from an empty file). */ envConfigPresent: boolean; envSecretsPresent: boolean; } -export function configDtoToEnvEntries(config: ConfigDTO | undefined): EnvEntry[] { - const vars = config?.envVariables; - if (!vars || typeof vars !== 'object') return []; - return Object.entries(vars) - .filter(([, value]) => value !== undefined && value !== null) +export type CloudAssetEnvRef = { + fileName?: string; + copyText?: string; + isArchived?: boolean; +}; + +type AssetKeyContext = { + localKeys: Set; + excludedKeys: Set; + staleKeys: Set; + cloudByFile: Map; +}; + +const activeCloudAssets = (cloudAssets?: CloudAssetEnvRef[]) => + (cloudAssets ?? []).filter( + (a) => typeof a.fileName === 'string' && a.fileName !== '' && a.isArchived !== true + ); + +function buildAssetKeyContext( + localAssetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): AssetKeyContext { + const cloudByFile = new Map(activeCloudAssets(cloudAssets).map((a) => [a.fileName as string, a])); + const localFiles = new Set(localAssetFileNames); + const localKeys = new Set(['assets']); + const staleKeys = new Set(); + + for (const fileName of localAssetFileNames) { + const cloudAsset = cloudByFile.get(fileName); + localKeys.add(resolveAssetEnvKey({ fileName, copyText: cloudAsset?.copyText })); + localKeys.add(deriveAssetEnvKey(fileName)); + } + for (const [fileName, asset] of cloudByFile) { + if (!localFiles.has(fileName)) { + staleKeys.add(resolveAssetEnvKey({ fileName, copyText: asset.copyText })); + } + } + + return { + localKeys, + staleKeys, + excludedKeys: new Set([...localKeys, ...staleKeys]), + cloudByFile, + }; +} + +function entriesEqual(a: EnvEntry[], b: EnvEntry[]): boolean { + const mapB = new Map(b.map((e) => [e.key, e.value])); + return a.length === mapB.size && a.every((e) => mapB.get(e.key) === e.value); +} + +function configEntriesEqual(a?: ConfigDTO, b?: ConfigDTO): boolean { + return entriesEqual(configDtoToEnvEntries(a), configDtoToEnvEntries(b)); +} + +function entriesFromRecord( + record: Record | undefined, + skip?: (key: string) => boolean +): EnvEntry[] { + if (!record) return []; + return Object.entries(record) + .filter(([key, value]) => !skip?.(key) && value !== undefined && value !== null) .map(([key, value]) => ({ key, value: String(value) })) .sort((a, b) => a.key.localeCompare(b.key)); } +function dtoFromEntries(entries: EnvEntry[], excludeKeys?: Set): ConfigDTO | undefined { + const envVariables: Record = {}; + for (const entry of entries) { + if (!excludeKeys?.has(entry.key)) envVariables[entry.key] = entry.value; + } + return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; +} + +function omitAssetKeys( + entries: EnvEntry[], + assetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): ConfigDTO | undefined { + const excludeKeys = cloudAssets + ? buildAssetKeyContext(assetFileNames, cloudAssets).excludedKeys + : collectAssetEnvKeys(assetFileNames); + return dtoFromEntries(entries, excludeKeys); +} + +function assetFileNameFromEnvValue(rawValue: string): string | undefined { + const value = rawValue.trim(); + if (!value) return undefined; + const base = path.basename(value.split('?')[0] ?? value); + return base.includes('.') ? base : undefined; +} + +export function configDtoToEnvEntries(config: ConfigDTO | undefined): EnvEntry[] { + return entriesFromRecord(config?.envVariables as Record | undefined); +} + export function secretsDtoToEnvEntries(secrets: SecretDTO | undefined): EnvEntry[] { if (!secrets || typeof secrets !== 'object') return []; const nested = secrets.secrets && typeof secrets.secrets === 'object' ? (secrets.secrets as Record) : (secrets as Record); - return Object.entries(nested) - .filter(([key, value]) => key !== 'secrets' && value !== undefined && value !== null) - .map(([key, value]) => ({ key, value: String(value) })) - .sort((a, b) => a.key.localeCompare(b.key)); + return entriesFromRecord(nested, (key) => key === 'secrets'); } export function collectAssetEnvKeys(assetFileNames: string[] = []): Set { return new Set(['assets', ...assetFileNames.map(deriveAssetEnvKey)]); } -function partitionConfigEnvVariables( - config: ConfigDTO | undefined, - assetFileNames: string[] = [] -): { asset: Record; nonAsset: Record } { - const assetKeys = collectAssetEnvKeys(assetFileNames); - const asset: Record = {}; - const nonAsset: Record = {}; - const vars = config?.envVariables; - if (!vars || typeof vars !== 'object') return { asset, nonAsset }; - - for (const [key, value] of Object.entries(vars)) { - if (value === undefined || value === null) continue; - const normalized = String(value); - if (assetKeys.has(key)) asset[key] = normalized; - else nonAsset[key] = normalized; - } - return { asset, nonAsset }; -} - -function configDtoFromEnvVariables(envVariables: Record): ConfigDTO | undefined { - return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; -} - -function buildConfigDtoFromEntries( - entries: EnvEntry[], - options?: { excludeKeys?: Set } -): ConfigDTO | undefined { - const envVariables: Record = {}; - for (const entry of entries) { - if (options?.excludeKeys?.has(entry.key)) continue; - envVariables[entry.key] = entry.value; - } - return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; -} - export function stripAssetKeysFromConfigDto( config: ConfigDTO | undefined, - assetFileNames: string[] = [] + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[] ): ConfigDTO | undefined { - const { nonAsset } = partitionConfigEnvVariables(config, assetFileNames); - return configDtoFromEnvVariables(nonAsset); + return omitAssetKeys(configDtoToEnvEntries(config), assetFileNames, cloudAssets); } export function buildConfigDtoFromEnvConfigFile( entries: EnvEntry[], - assetFileNames: string[] = [] + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[] ): ConfigDTO | undefined { - return buildConfigDtoFromEntries(entries, { - excludeKeys: collectAssetEnvKeys(assetFileNames), - }); + return omitAssetKeys(entries, assetFileNames, cloudAssets); } -export function buildLocalAssetConfigFromEnvFile( +export const buildConfigDtoFromEnvEntries = dtoFromEntries; + +export function buildSecretsDtoFromEnvSecretsFile(entries: EnvEntry[]): SecretDTO | undefined { + return entries.length > 0 + ? { secrets: Object.fromEntries(entries.map((e) => [e.key, e.value])) } + : undefined; +} + +export function pruneStaleAssetEnvEntries( entries: EnvEntry[], - assetFileNames: string[] = [] -): ConfigDTO | undefined { - const assetKeys = collectAssetEnvKeys(assetFileNames); - const envVariables: Record = {}; - for (const entry of entries) { - if (assetKeys.has(entry.key)) { - envVariables[entry.key] = entry.value; - } - } - return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; + assetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): EnvEntry[] { + const { staleKeys } = buildAssetKeyContext(assetFileNames, cloudAssets); + const localFiles = new Set(assetFileNames); + return entries.filter((entry) => { + if (entry.key === 'assets') return assetFileNames.length > 0; + if (staleKeys.has(entry.key)) return false; + const fileName = assetFileNameFromEnvValue(entry.value); + return !(fileName && !localFiles.has(fileName) && entry.key === deriveAssetEnvKey(fileName)); + }); } -export function mergeConfigDtoForPush( - localNonAssetConfig: ConfigDTO | undefined, +export function buildPushConfigDto( + localEnv: LocalEnvFiles, cloudConfig: ConfigDTO | undefined, assetFileNames: string[] = [], - localAssetConfig?: ConfigDTO + cloudAssets?: CloudAssetEnvRef[] ): ConfigDTO { - const { asset: cloudAsset } = partitionConfigEnvVariables(cloudConfig, assetFileNames); - const localAsset = localAssetConfig?.envVariables ?? {}; - const envVariables: Record = {}; - for (const [key, value] of Object.entries(localNonAssetConfig?.envVariables ?? {})) { - if (typeof value === 'string') { - envVariables[key] = value; - } + const ctx = buildAssetKeyContext(assetFileNames, cloudAssets); + const localAsset: Record = {}; + for (const entry of localEnv.envConfig) { + if (ctx.localKeys.has(entry.key)) localAsset[entry.key] = entry.value; } - if (assetFileNames.length > 0) { - if (typeof localAsset.assets === 'string' && localAsset.assets.trim() !== '') { - envVariables.assets = localAsset.assets; - } else if (typeof cloudAsset.assets === 'string' && cloudAsset.assets.trim() !== '') { - envVariables.assets = cloudAsset.assets; - } + const envVariables: Record = { + ...((dtoFromEntries(localEnv.envConfig, ctx.excludedKeys)?.envVariables ?? {}) as Record< + string, + string + >), + }; - for (const fileName of assetFileNames) { - const envKey = deriveAssetEnvKey(fileName); - const value = localAsset[envKey]; - if (typeof value === 'string') { - envVariables[envKey] = value; - } - } + if (assetFileNames.length === 0) return { envVariables }; + + const cloudAssetVars = cloudConfig?.envVariables ?? {}; + const assetsBase = + (typeof localAsset.assets === 'string' ? localAsset.assets.trim() : '') || + (typeof cloudAssetVars.assets === 'string' ? cloudAssetVars.assets.trim() : ''); + if (assetsBase) envVariables.assets = assetsBase; + + for (const fileName of assetFileNames) { + const envKey = resolveAssetEnvKey({ + fileName, + copyText: ctx.cloudByFile.get(fileName)?.copyText, + }); + const value = localAsset[envKey] ?? localAsset[deriveAssetEnvKey(fileName)]; + if (typeof value === 'string') envVariables[envKey] = value; } return { envVariables }; } -function entriesToRecord(entries: EnvEntry[]): Record { - return Object.fromEntries(entries.map((entry) => [entry.key, entry.value])); -} - -export function buildSecretsDtoFromEnvSecretsFile(entries: EnvEntry[]): SecretDTO | undefined { - return entries.length > 0 ? { secrets: entriesToRecord(entries) } : undefined; -} - -export function buildConfigDtoFromEnvEntries(entries: EnvEntry[]): ConfigDTO | undefined { - return buildConfigDtoFromEntries(entries); -} - export function warnIfMissingEnvFilesForPush( localEnv: LocalEnvFiles, cloudEnv: CloudEnvState, assetFileNames: string[] = [], warn: (message: string) => void ): void { - if ( - !localEnv.envConfigPresent && - configDtoToEnvEntries(stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames)).length > 0 - ) { - warn( - '.env.config is missing locally. Run `ensemble pull` to restore env vars from cloud. Config env push skipped.' - ); - } - if (!localEnv.envSecretsPresent && secretsDtoToEnvEntries(cloudEnv.secrets).length > 0) { - warn( - '.env.secrets is missing locally. Run `ensemble pull` to restore secrets from cloud. Secrets env push skipped.' - ); + const checks: Array<[boolean, string]> = [ + [ + !localEnv.envConfigPresent && + configDtoToEnvEntries(stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames)).length > + 0, + '.env.config is missing locally. Run `ensemble pull` to restore env vars from cloud. Config env push skipped.', + ], + [ + !localEnv.envSecretsPresent && secretsDtoToEnvEntries(cloudEnv.secrets).length > 0, + '.env.secrets is missing locally. Run `ensemble pull` to restore secrets from cloud. Secrets env push skipped.', + ], + ]; + for (const [missing, message] of checks) { + if (missing) warn(message); } } @@ -184,19 +237,12 @@ export async function readProjectEnvFiles(projectRoot: string): Promise [e.key, e.value])); - const mapB = new Map(b.map((e) => [e.key, e.value])); - if (mapA.size !== mapB.size) return false; - for (const [key, value] of mapA) { - if (mapB.get(key) !== value) return false; - } - return true; + return { + envConfigPresent, + envSecretsPresent, + envConfig: envConfigPresent ? await readEnvFile(projectRoot, '.env.config') : [], + envSecrets: envSecretsPresent ? await readEnvFile(projectRoot, '.env.secrets') : [], + }; } export function mergeAssetFileNamesForEnvCompare( @@ -204,101 +250,80 @@ export function mergeAssetFileNamesForEnvCompare( cloudAssets: Array<{ fileName?: string; isArchived?: boolean }> | undefined = [] ): string[] { const fromCloud = (cloudAssets ?? []) - .filter((asset) => asset.isArchived !== true) - .map((asset) => asset.fileName) - .filter((fileName): fileName is string => typeof fileName === 'string' && fileName.length > 0); + .filter((a) => a.isArchived !== true && typeof a.fileName === 'string' && a.fileName !== '') + .map((a) => a.fileName as string); return [...new Set([...localAssetFileNames, ...fromCloud])]; } -function assetEnvEntriesMatchCloud( +export function envConfigEntriesMatchCloud( localEntries: EnvEntry[], cloudConfig: ConfigDTO | undefined, - assetFileNames: string[] = [] + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[] ): boolean { + if ( + !configEntriesEqual( + buildConfigDtoFromEnvConfigFile(localEntries, assetFileNames, cloudAssets), + stripAssetKeysFromConfigDto(cloudConfig, assetFileNames, cloudAssets) + ) + ) { + return false; + } + const assetKeys = collectAssetEnvKeys(assetFileNames); const localMap = new Map(localEntries.map((entry) => [entry.key, entry.value])); - const { asset: cloudAssetVars } = partitionConfigEnvVariables(cloudConfig, assetFileNames); + const cloudAssetVars = cloudConfig?.envVariables ?? {}; for (const key of assetKeys) { const cloudValue = cloudAssetVars[key]; - if (cloudValue === undefined) continue; - if (localMap.get(key) !== cloudValue) return false; + if (cloudValue !== undefined && localMap.get(key) !== String(cloudValue)) return false; } return true; } -export function envConfigEntriesMatchCloud( - localEntries: EnvEntry[], - cloudConfig: ConfigDTO | undefined, - assetFileNames: string[] = [] -): boolean { - const localComparable = configDtoToEnvEntries( - buildConfigDtoFromEnvConfigFile(localEntries, assetFileNames) - ); - const cloudComparable = configDtoToEnvEntries( - stripAssetKeysFromConfigDto(cloudConfig, assetFileNames) - ); - return ( - entriesEqual(localComparable, cloudComparable) && - assetEnvEntriesMatchCloud(localEntries, cloudConfig, assetFileNames) - ); -} - export function envSecretsEntriesMatchCloud( localEntries: EnvEntry[], cloudSecrets: SecretDTO | undefined ): boolean { - const localDto = buildSecretsDtoFromEnvSecretsFile(localEntries); - const cloudEntries = secretsDtoToEnvEntries(cloudSecrets); - const localComparable = secretsDtoToEnvEntries(localDto); - return entriesEqual(localComparable, cloudEntries); + return entriesEqual( + secretsDtoToEnvEntries(buildSecretsDtoFromEnvSecretsFile(localEntries)), + secretsDtoToEnvEntries(cloudSecrets) + ); } export interface EnvPushDiff { configChanged: boolean; secretsChanged: boolean; - local: { - config?: ConfigDTO; - secrets?: SecretDTO; - }; - cloud: { - config?: ConfigDTO; - secrets?: SecretDTO; - }; + local: { config?: ConfigDTO; secrets?: SecretDTO }; + cloud: { config?: ConfigDTO; secrets?: SecretDTO }; } export function buildEnvPushDiff( localEnv: LocalEnvFiles, cloudEnv: CloudEnvState, - assetFileNames: string[] = [] + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[] ): EnvPushDiff { - const configFilePresent = localEnv.envConfigPresent ?? true; - const secretsFilePresent = localEnv.envSecretsPresent ?? true; const configChanged = - configFilePresent && - !envConfigEntriesMatchCloud(localEnv.envConfig, cloudEnv.config, assetFileNames); + localEnv.envConfigPresent && + !configEntriesEqual( + buildPushConfigDto(localEnv, cloudEnv.config, assetFileNames, cloudAssets), + cloudEnv.config + ); const secretsChanged = - secretsFilePresent && !envSecretsEntriesMatchCloud(localEnv.envSecrets, cloudEnv.secrets); + localEnv.envSecretsPresent && + !envSecretsEntriesMatchCloud(localEnv.envSecrets, cloudEnv.secrets); return { configChanged, secretsChanged, local: { ...(configChanged && { - config: buildConfigDtoFromEnvConfigFile(localEnv.envConfig, assetFileNames) ?? { - envVariables: {}, - }, - }), - ...(secretsChanged && { - secrets: buildSecretsDtoFromEnvSecretsFile(localEnv.envSecrets), + config: buildPushConfigDto(localEnv, cloudEnv.config, assetFileNames, cloudAssets), }), + ...(secretsChanged && { secrets: buildSecretsDtoFromEnvSecretsFile(localEnv.envSecrets) }), }, cloud: { - ...(configChanged && - cloudEnv.config && { - config: stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames) ?? { - envVariables: {}, - }, - }), + ...(configChanged && cloudEnv.config && { config: cloudEnv.config }), ...(secretsChanged && cloudEnv.secrets && { secrets: cloudEnv.secrets }), }, }; @@ -326,7 +351,7 @@ export function computeEnvPullChanges( assetFileNames ); const secretsMatch = envSecretsEntriesMatchCloud(localEnv?.envSecrets ?? [], cloudSecrets); - const filesToUpdate: Array<'.env.config' | '.env.secrets'> = []; + const filesToUpdate: EnvPullChanges['filesToUpdate'] = []; if (!configMatch) filesToUpdate.push('.env.config'); if (!secretsMatch) filesToUpdate.push('.env.secrets'); return { @@ -343,40 +368,55 @@ export interface EnvPushState { diff: EnvPushDiff; pushConfigDto?: ConfigDTO; pushSecretsDto?: SecretDTO; + pendingLocalEnvConfigWrite?: EnvEntry[]; } export async function prepareEnvPushState(params: { projectRoot: string; cloudEnv: CloudEnvState; assetFileNames: string[]; + cloudAssets?: CloudAssetEnvRef[]; warn: (message: string) => void; }): Promise { - const localEnv = await readProjectEnvFiles(params.projectRoot); - const diff = buildEnvPushDiff(localEnv, params.cloudEnv, params.assetFileNames); + const localEnvRaw = await readProjectEnvFiles(params.projectRoot); + const prunedEnvConfig = localEnvRaw.envConfigPresent + ? pruneStaleAssetEnvEntries(localEnvRaw.envConfig, params.assetFileNames, params.cloudAssets) + : localEnvRaw.envConfig; + const localEnv = { ...localEnvRaw, envConfig: prunedEnvConfig }; + const diff = buildEnvPushDiff( + localEnv, + params.cloudEnv, + params.assetFileNames, + params.cloudAssets + ); + warnIfMissingEnvFilesForPush(localEnv, params.cloudEnv, params.assetFileNames, params.warn); - const pushConfigDto = - diff.configChanged && diff.local.config - ? mergeConfigDtoForPush( - diff.local.config, + return { + localEnv, + diff, + pushConfigDto: localEnvRaw.envConfigPresent + ? buildPushConfigDto( + localEnv, params.cloudEnv.config, params.assetFileNames, - buildLocalAssetConfigFromEnvFile(localEnv.envConfig, params.assetFileNames) + params.cloudAssets ) - : diff.local.config; - - return { localEnv, diff, pushConfigDto, pushSecretsDto: diff.local.secrets }; + : undefined, + pushSecretsDto: diff.local.secrets, + ...(localEnvRaw.envConfigPresent && + !entriesEqual(prunedEnvConfig, localEnvRaw.envConfig) && { + pendingLocalEnvConfigWrite: prunedEnvConfig, + }), + }; } -/** restores `.env.config` from a release snapshot; secrets are never included in releases */ export async function applyReleaseConfigToFs( projectRoot: string, config: ConfigDTO | undefined ): Promise { const configEntries = configDtoToEnvEntries(config); - if (configEntries.length > 0) { - await writeEnvFile(projectRoot, '.env.config', configEntries); - } + if (configEntries.length > 0) await writeEnvFile(projectRoot, '.env.config', configEntries); } export async function applyCloudEnvToFs( @@ -384,36 +424,18 @@ export async function applyCloudEnvToFs( cloudEnv: CloudEnvState, assetFileNames: string[] = [] ): Promise { - await upsertCloudAssetConfigEntries(projectRoot, cloudEnv.config, assetFileNames); + const assetKeys = collectAssetEnvKeys(assetFileNames); + const cloudVars = cloudEnv.config?.envVariables ?? {}; + const assetEntries = [...assetKeys] + .map((key) => ({ key, value: cloudVars[key] })) + .filter((entry): entry is EnvEntry => typeof entry.value === 'string'); + if (assetEntries.length > 0) await upsertEnvFile(projectRoot, '.env.config', assetEntries); - const configEntries = configDtoToEnvEntries( + const existing = await readEnvFile(projectRoot, '.env.config'); + const keptAssetEntries = existing.filter((entry) => assetKeys.has(entry.key)); + const nonAssetEntries = configDtoToEnvEntries( stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames) ); - await syncEnvConfigNonAssetEntries(projectRoot, configEntries, assetFileNames); - - const secretEntries = secretsDtoToEnvEntries(cloudEnv.secrets); - await writeEnvFile(projectRoot, '.env.secrets', secretEntries); -} - -async function upsertCloudAssetConfigEntries( - projectRoot: string, - cloudConfig: ConfigDTO | undefined, - assetFileNames: string[] = [] -): Promise { - const { asset } = partitionConfigEnvVariables(cloudConfig, assetFileNames); - const entries = Object.entries(asset).map(([key, value]) => ({ key, value })); - if (entries.length > 0) { - await upsertEnvFile(projectRoot, '.env.config', entries); - } -} - -async function syncEnvConfigNonAssetEntries( - projectRoot: string, - nonAssetEntries: EnvEntry[], - assetFileNames: string[] = [] -): Promise { - const assetKeys = collectAssetEnvKeys(assetFileNames); - const existing = await readEnvFile(projectRoot, '.env.config'); - const assetEntries = existing.filter((entry) => assetKeys.has(entry.key)); - await writeEnvFile(projectRoot, '.env.config', [...assetEntries, ...nonAssetEntries]); + await writeEnvFile(projectRoot, '.env.config', [...keptAssetEntries, ...nonAssetEntries]); + await writeEnvFile(projectRoot, '.env.secrets', secretsDtoToEnvEntries(cloudEnv.secrets)); } diff --git a/src/core/pullAssets.ts b/src/core/pullAssets.ts index 61292f8..16d90d5 100644 --- a/src/core/pullAssets.ts +++ b/src/core/pullAssets.ts @@ -76,6 +76,10 @@ export function deriveAssetEnvKey(fileName: string): string { return fileName.replace(/[^\w]+/g, '_'); } +export function resolveAssetEnvKey(asset: { fileName: string; copyText?: string }): string { + return extractEnvKeyFromCopyText(asset.copyText) ?? deriveAssetEnvKey(asset.fileName); +} + function tryDeriveAssetBaseAndValue( publicUrl: string, fileName: string @@ -143,7 +147,7 @@ export function buildEnvConfigForCloudAssets( const derived = tryDeriveAssetBaseAndValue(url, fileName); if (!derived) continue; - const envKey = extractEnvKeyFromCopyText(a.copyText) ?? deriveAssetEnvKey(fileName); + const envKey = resolveAssetEnvKey({ fileName, copyText: a.copyText }); derivedByFile.set(fileName, { ...derived, envKey, fileName }); baseCounts.set(derived.baseUrl, (baseCounts.get(derived.baseUrl) ?? 0) + 1); } diff --git a/tests/core/bundleDiff.test.ts b/tests/core/bundleDiff.test.ts index 65c577c..3096ef8 100644 --- a/tests/core/bundleDiff.test.ts +++ b/tests/core/bundleDiff.test.ts @@ -75,6 +75,37 @@ describe('computeBundleDiff', () => { }); }); + it('does not treat cloud-archived assets as new when local assets dir is empty', () => { + const archived = (fileName: string, id: string) => ({ + ...asset(id, fileName, `builds/app/assets/${fileName}`), + isArchived: true, + }); + const cloudApp = { + id: 'app1', + name: 'App', + assets: [ + archived('gone.pdf', 'a1'), + archived('also-gone.png', 'a2'), + asset('a3', 'still-active.png', 'builds/app/assets/still-active.png'), + ], + }; + const bundle = { + id: 'app1', + name: 'App', + assets: cloudApp.assets.map((item) => ({ ...item, isArchived: true })), + }; + const localApp = { id: 'app1', name: 'App' }; + + const diff = computeBundleDiff(bundle, cloudApp, localApp); + + expect(diff.assets.new).toHaveLength(0); + expect(diff.assets.changed).toHaveLength(1); + expect(diff.assets.changed[0]).toMatchObject({ + fileName: 'still-active.png', + isArchived: true, + }); + }); + it('detects changed screens', () => { const cloud: ApplicationDTO = { id: 'app1', diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 29ac431..2d65a6d 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -4,18 +4,32 @@ import path from 'path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { writeEnvFile, type EnvEntry } from '../../src/core/envConfig.js'; import { applyCloudEnvToFs, applyReleaseConfigToFs, buildEnvPushDiff, + buildPushConfigDto, computeEnvPullChanges, - mergeConfigDtoForPush, + pruneStaleAssetEnvEntries, prepareEnvPushState, warnIfMissingEnvFilesForPush, type CloudEnvState, type LocalEnvFiles, } from '../../src/core/envSync.js'; +function localEnvFromParts( + configEntries: EnvEntry[], + assetEntries: EnvEntry[] = [] +): LocalEnvFiles { + return { + envConfig: [...configEntries, ...assetEntries], + envSecrets: [], + envConfigPresent: true, + envSecretsPresent: false, + }; +} + describe('envSync', () => { let tmpDir: string; @@ -58,6 +72,7 @@ describe('envSync', () => { local: LocalEnvFiles; cloud: CloudEnvState; assets: string[]; + cloudAssets?: Array<{ fileName?: string; copyText?: string }>; configChanged: boolean; secretsChanged: boolean; cloudConfig?: Record; @@ -112,9 +127,13 @@ describe('envSync', () => { secretsChanged: false, }, { - name: 'strips asset keys from cloud diff', + name: 'shows full push config vs cloud including asset keys', local: { - envConfig: [{ key: 'E1', value: 'EV11' }], + envConfig: [ + { key: 'E1', value: 'EV11' }, + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'logo_png', value: 'logo.png?local=abc' }, + ], envSecrets: [], envConfigPresent: true, envSecretsPresent: true, @@ -132,13 +151,64 @@ describe('envSync', () => { assets: ['logo.png'], configChanged: true, secretsChanged: false, - cloudConfig: { E1: 'EV1', E2: 'EV2' }, - localConfig: { E1: 'EV11' }, + cloudConfig: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png', + E1: 'EV1', + E2: 'EV2', + }, + localConfig: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?local=abc', + E1: 'EV11', + }, + }, + { + name: 'flags configChanged when only stale asset env keys differ from cloud', + local: { + envConfig: [{ key: 'E1', value: 'EV1' }], + envSecrets: [], + envConfigPresent: true, + envSecretsPresent: true, + }, + cloud: { + config: { + envVariables: { + assets: 'https://cdn.example.com/', + DSA_Viva_rtf: 'DSA%20Viva.rtf?token=abc', + github_html: 'first%20token%20github.html?token=def', + E1: 'EV1', + }, + }, + }, + assets: [], + cloudAssets: [ + { fileName: 'DSA Viva.rtf' }, + { fileName: 'first token github.html', copyText: '${env.github_html}' }, + ], + configChanged: true, + secretsChanged: false, + localConfig: { E1: 'EV1' }, + cloudConfig: { + assets: 'https://cdn.example.com/', + DSA_Viva_rtf: 'DSA%20Viva.rtf?token=abc', + github_html: 'first%20token%20github.html?token=def', + E1: 'EV1', + }, }, ])( '$name', - ({ local, cloud, assets, configChanged, secretsChanged, cloudConfig, localConfig }) => { - const diff = buildEnvPushDiff(local, cloud, assets); + ({ + local, + cloud, + assets, + cloudAssets, + configChanged, + secretsChanged, + cloudConfig, + localConfig, + }) => { + const diff = buildEnvPushDiff(local, cloud, assets, cloudAssets); expect(diff.configChanged).toBe(configChanged); expect(diff.secretsChanged).toBe(secretsChanged); if (!configChanged && !secretsChanged) { @@ -151,10 +221,19 @@ describe('envSync', () => { } ); - it('mergeConfigDtoForPush uses local asset keys only and drops removed non-asset keys', () => { + it('buildPushConfigDto keeps local assets and drops deleted cloud asset keys', () => { expect( - mergeConfigDtoForPush( - { envVariables: { E1: 'EV11', E2: 'EV2' } }, + buildPushConfigDto( + localEnvFromParts( + [ + { key: 'E1', value: 'EV11' }, + { key: 'E2', value: 'EV2' }, + ], + [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'logo_png', value: 'logo.png?local=abc' }, + ] + ), { envVariables: { assets: 'https://cdn.example.com/', @@ -162,24 +241,47 @@ describe('envSync', () => { E1: 'EV1', }, }, - ['logo.png'], + ['logo.png'] + ).envVariables + ).toEqual({ + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?local=abc', + E1: 'EV11', + E2: 'EV2', + }); + + expect( + buildPushConfigDto( + localEnvFromParts( + [{ key: 'E1', value: 'EV11' }], + [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'report_html', value: 'report.html?local=abc' }, + ] + ), { envVariables: { assets: 'https://cdn.example.com/', - logo_png: 'logo.png?local=abc', + report_html: 'report.html?token=abc', + sheet_xlsx: 'sheet.xlsx?token=def', + E1: 'EV1', }, - } + }, + ['report.html'], + [ + { fileName: 'report.html', copyText: '${env.report_html}' }, + { fileName: 'sheet.xlsx', copyText: '${env.sheet_xlsx}' }, + ] ).envVariables ).toEqual({ assets: 'https://cdn.example.com/', - logo_png: 'logo.png?local=abc', + report_html: 'report.html?local=abc', E1: 'EV11', - E2: 'EV2', }); expect( - mergeConfigDtoForPush( - { envVariables: { E1: 'EV11' } }, + buildPushConfigDto( + localEnvFromParts([{ key: 'E1', value: 'EV11' }]), { envVariables: { assets: 'https://cdn.example.com/', @@ -188,12 +290,59 @@ describe('envSync', () => { E2: 'EV2', }, }, - [], - undefined + [] ).envVariables ).toEqual({ E1: 'EV11' }); }); + it.each<{ + name: string; + entries: Array<{ key: string; value: string }>; + assetFileNames: string[]; + cloudAssets?: Array<{ fileName?: string; copyText?: string }>; + expected: Array<{ key: string; value: string }>; + }>([ + { + name: 'removes copyText and derived keys for deleted assets', + entries: [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'report_html', value: 'report.html?token=abc' }, + { key: 'sheet_xlsx', value: 'sheet.xlsx?token=def' }, + { key: 'MIH_4735_pdf', value: 'MIH-4735.pdf?token=xyz' }, + { key: 'E1', value: 'EV1' }, + ], + assetFileNames: [], + cloudAssets: [ + { fileName: 'report.html', copyText: '${env.report_html}' }, + { fileName: 'sheet.xlsx', copyText: '${env.sheet_xlsx}' }, + ], + expected: [{ key: 'E1', value: 'EV1' }], + }, + { + name: 'keeps local assets and user config', + entries: [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'img1_png', value: 'img1.png?token=abc' }, + { key: 'deleted_png', value: 'deleted.png?token=def' }, + { key: 'E1', value: 'EV1' }, + ], + assetFileNames: ['img1.png'], + expected: [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'img1_png', value: 'img1.png?token=abc' }, + { key: 'E1', value: 'EV1' }, + ], + }, + { + name: 'keeps non-asset keys whose value looks like a file URL', + entries: [{ key: 'download_url', value: 'https://cdn.example.com/guide.pdf?token=abc' }], + assetFileNames: [], + expected: [{ key: 'download_url', value: 'https://cdn.example.com/guide.pdf?token=abc' }], + }, + ])('$name', ({ entries, assetFileNames, cloudAssets, expected }) => { + expect(pruneStaleAssetEnvEntries(entries, assetFileNames, cloudAssets)).toEqual(expected); + }); + it('computeEnvPullChanges flags config mismatch including missing asset env keys', () => { const result = computeEnvPullChanges( { @@ -238,6 +387,31 @@ describe('envSync', () => { expect(state.pushConfigDto?.envVariables).toEqual({ E1: 'EV11' }); }); + it('prepareEnvPushState prunes stale asset env keys only after confirm', async () => { + await fs.writeFile( + path.join(tmpDir, '.env.config'), + 'assets=https://cdn.example.com/\nimg1_png=img1.png?token=abc\ndel_png=del.png?token=def\nE1=EV11\n', + 'utf8' + ); + + const state = await prepareEnvPushState({ + projectRoot: tmpDir, + cloudEnv: { config: { envVariables: { E1: 'EV1' } } }, + assetFileNames: ['img1.png'], + warn: () => {}, + }); + + const envConfigBeforeConfirm = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + expect(envConfigBeforeConfirm).toContain('del_png='); + expect(state.pendingLocalEnvConfigWrite?.some((entry) => entry.key === 'del_png')).toBe(false); + + await writeEnvFile(tmpDir, '.env.config', state.pendingLocalEnvConfigWrite!); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + expect(envConfig).toContain('img1_png=img1.png?token=abc'); + expect(envConfig).not.toContain('del_png='); + }); + it('applyReleaseConfigToFs restores full snapshot config', async () => { await applyReleaseConfigToFs(tmpDir, { envVariables: { From ce9870c71a103a499f929d9c6ba511f88daa7039 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Tue, 16 Jun 2026 22:03:54 +0500 Subject: [PATCH 07/10] fix(push): confirm cloud env/secrets wipe Missing or empty .env.config/.env.secrets now warn and prompt [y/N] before clearing cloud values; empty secrets push {} --- src/commands/push.ts | 41 +++++++++++- src/core/envSync.ts | 106 +++++++++++++++++--------------- tests/commands/pushPull.test.ts | 43 +++++++++++++ tests/core/envSync.test.ts | 38 ++++-------- 4 files changed, 150 insertions(+), 78 deletions(-) diff --git a/src/commands/push.ts b/src/commands/push.ts index 5885362..d731526 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -344,7 +344,6 @@ export async function pushCommand(options: PushOptions = {}): Promise { cloudEnv: { config: cloudApp.config, secrets: cloudApp.secrets }, assetFileNames, cloudAssets: cloudApp.assets, - warn: (message) => ui.warn(message), }); const { diff: envPushDiff, @@ -354,6 +353,7 @@ export async function pushCommand(options: PushOptions = {}): Promise { } = envPush; const envConfigChanged = envPushDiff.configChanged; const envSecretsChanged = envPushDiff.secretsChanged; + const { wouldClearConfig, wouldClearSecrets } = envPushDiff; await writeVerboseJson(root, 'ensemble-bundle.json', bundle, { verbose, @@ -413,6 +413,11 @@ export async function pushCommand(options: PushOptions = {}): Promise { if (envConfigChanged || envSecretsChanged) { ui.note('Env file changes would also be pushed (.env.config / .env.secrets).'); } + if (wouldClearConfig || wouldClearSecrets) { + ui.warn( + 'Push would delete all cloud env/secrets (.env.config and/or .env.secrets missing or empty).' + ); + } return; } @@ -430,6 +435,8 @@ export async function pushCommand(options: PushOptions = {}): Promise { console.log(' env:\n ✏️ modified .env.secrets'); } + const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); + const appHome = appConfig.appHome as string | undefined; const cloudHome = getCloudHomeScreenName(cloudApp); const hasHomeConflict = appHome && cloudHome && appHome !== cloudHome; @@ -447,10 +454,40 @@ export async function pushCommand(options: PushOptions = {}): Promise { } } + if (wouldClearConfig || wouldClearSecrets) { + const targets = [ + wouldClearConfig && 'env variables (.env.config)', + wouldClearSecrets && 'secrets (.env.secrets)', + ].filter((t): t is string => Boolean(t)); + ui.warn(`Pushing will delete all cloud ${targets.join(' and ')}.`); + + if (!options.yes) { + if (!isInteractive) { + ui.error( + 'Refusing to clear cloud env/secrets non-interactively without --yes. Re-run with --dry-run to inspect changes.' + ); + process.exitCode = 1; + return; + } + const { proceed: clearEnv } = await prompts({ + type: 'confirm', + name: 'proceed', + message: `Delete all ${targets.join(' and ')} from cloud? Continue? [y/N]`, + initial: false, + }); + if (!clearEnv) { + ui.warn('Push cancelled.'); + process.exitCode = 130; + return; + } + } else { + ui.note('Proceeding without interactive confirmation because --yes was provided.'); + } + } + const manifestNeedsRefresh = hasManifestRelevantChanges(cloudApp, plan.diff); let confirmed = options.yes ?? false; - const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); const hasDeletes = summary.counts.deleted > 0; const largeChangeSet = yamlChangeTotal >= DESTRUCTIVE_CHANGE_PROMPT_THRESHOLD; diff --git a/src/core/envSync.ts b/src/core/envSync.ts index 4f49c72..3c52ffa 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -155,6 +155,39 @@ export function buildSecretsDtoFromEnvSecretsFile(entries: EnvEntry[]): SecretDT : undefined; } +function localNonAssetConfigEntries( + localEnv: LocalEnvFiles, + assetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): EnvEntry[] { + if (!localEnv.envConfigPresent) return []; + return configDtoToEnvEntries( + buildConfigDtoFromEnvConfigFile(localEnv.envConfig, assetFileNames, cloudAssets) + ); +} + +function wouldClearConfigOnPush( + localEnv: LocalEnvFiles, + cloudConfig: ConfigDTO | undefined, + assetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): boolean { + const cloudNonAsset = configDtoToEnvEntries( + stripAssetKeysFromConfigDto(cloudConfig, assetFileNames, cloudAssets) + ); + return ( + cloudNonAsset.length > 0 && + localNonAssetConfigEntries(localEnv, assetFileNames, cloudAssets).length === 0 + ); +} + +function wouldClearSecretsOnPush(localEnv: LocalEnvFiles, cloudSecrets?: SecretDTO): boolean { + return ( + secretsDtoToEnvEntries(cloudSecrets).length > 0 && + (!localEnv.envSecretsPresent || localEnv.envSecrets.length === 0) + ); +} + export function pruneStaleAssetEnvEntries( entries: EnvEntry[], assetFileNames: string[], @@ -209,29 +242,6 @@ export function buildPushConfigDto( return { envVariables }; } -export function warnIfMissingEnvFilesForPush( - localEnv: LocalEnvFiles, - cloudEnv: CloudEnvState, - assetFileNames: string[] = [], - warn: (message: string) => void -): void { - const checks: Array<[boolean, string]> = [ - [ - !localEnv.envConfigPresent && - configDtoToEnvEntries(stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames)).length > - 0, - '.env.config is missing locally. Run `ensemble pull` to restore env vars from cloud. Config env push skipped.', - ], - [ - !localEnv.envSecretsPresent && secretsDtoToEnvEntries(cloudEnv.secrets).length > 0, - '.env.secrets is missing locally. Run `ensemble pull` to restore secrets from cloud. Secrets env push skipped.', - ], - ]; - for (const [missing, message] of checks) { - if (missing) warn(message); - } -} - export async function readProjectEnvFiles(projectRoot: string): Promise { const [envConfigPresent, envSecretsPresent] = await Promise.all([ envFileExists(projectRoot, '.env.config'), @@ -284,15 +294,14 @@ export function envSecretsEntriesMatchCloud( localEntries: EnvEntry[], cloudSecrets: SecretDTO | undefined ): boolean { - return entriesEqual( - secretsDtoToEnvEntries(buildSecretsDtoFromEnvSecretsFile(localEntries)), - secretsDtoToEnvEntries(cloudSecrets) - ); + return entriesEqual(localEntries, secretsDtoToEnvEntries(cloudSecrets)); } export interface EnvPushDiff { configChanged: boolean; secretsChanged: boolean; + wouldClearConfig: boolean; + wouldClearSecrets: boolean; local: { config?: ConfigDTO; secrets?: SecretDTO }; cloud: { config?: ConfigDTO; secrets?: SecretDTO }; } @@ -303,24 +312,33 @@ export function buildEnvPushDiff( assetFileNames: string[] = [], cloudAssets?: CloudAssetEnvRef[] ): EnvPushDiff { + const pushConfig = buildPushConfigDto(localEnv, cloudEnv.config, assetFileNames, cloudAssets); + const wouldClearConfig = wouldClearConfigOnPush( + localEnv, + cloudEnv.config, + assetFileNames, + cloudAssets + ); + const wouldClearSecrets = wouldClearSecretsOnPush(localEnv, cloudEnv.secrets); const configChanged = - localEnv.envConfigPresent && - !configEntriesEqual( - buildPushConfigDto(localEnv, cloudEnv.config, assetFileNames, cloudAssets), - cloudEnv.config - ); + wouldClearConfig || + (localEnv.envConfigPresent && !configEntriesEqual(pushConfig, cloudEnv.config)); const secretsChanged = - localEnv.envSecretsPresent && - !envSecretsEntriesMatchCloud(localEnv.envSecrets, cloudEnv.secrets); + wouldClearSecrets || + (localEnv.envSecretsPresent && + !entriesEqual(localEnv.envSecrets, secretsDtoToEnvEntries(cloudEnv.secrets))); + const pushSecrets: SecretDTO = { + secrets: Object.fromEntries(localEnv.envSecrets.map((e) => [e.key, e.value])), + }; return { configChanged, secretsChanged, + wouldClearConfig, + wouldClearSecrets, local: { - ...(configChanged && { - config: buildPushConfigDto(localEnv, cloudEnv.config, assetFileNames, cloudAssets), - }), - ...(secretsChanged && { secrets: buildSecretsDtoFromEnvSecretsFile(localEnv.envSecrets) }), + ...(configChanged && { config: pushConfig }), + ...(secretsChanged && { secrets: pushSecrets }), }, cloud: { ...(configChanged && cloudEnv.config && { config: cloudEnv.config }), @@ -376,7 +394,6 @@ export async function prepareEnvPushState(params: { cloudEnv: CloudEnvState; assetFileNames: string[]; cloudAssets?: CloudAssetEnvRef[]; - warn: (message: string) => void; }): Promise { const localEnvRaw = await readProjectEnvFiles(params.projectRoot); const prunedEnvConfig = localEnvRaw.envConfigPresent @@ -390,19 +407,10 @@ export async function prepareEnvPushState(params: { params.cloudAssets ); - warnIfMissingEnvFilesForPush(localEnv, params.cloudEnv, params.assetFileNames, params.warn); - return { localEnv, diff, - pushConfigDto: localEnvRaw.envConfigPresent - ? buildPushConfigDto( - localEnv, - params.cloudEnv.config, - params.assetFileNames, - params.cloudAssets - ) - : undefined, + pushConfigDto: diff.local.config, pushSecretsDto: diff.local.secrets, ...(localEnvRaw.envConfigPresent && !entriesEqual(prunedEnvConfig, localEnvRaw.envConfig) && { diff --git a/tests/commands/pushPull.test.ts b/tests/commands/pushPull.test.ts index d4fa01f..cac23fd 100644 --- a/tests/commands/pushPull.test.ts +++ b/tests/commands/pushPull.test.ts @@ -1040,4 +1040,47 @@ describe('push/pull integration (commands)', () => { expect(payload.config?.envVariables?.API_URL).toBe('https://local.example.com'); expect(payload.secrets?.secrets?.S1).toBe('local-secret'); }); + + it.each([ + ['deleted', async () => {}], + [ + 'empty', + async () => { + await fs.writeFile(path.join(projectRoot, '.env.secrets'), '', 'utf8'); + }, + ], + ])('push clears cloud secrets when .env.secrets is %s', async (_label, setupSecrets) => { + await setupSecrets(); + (resolveAppContext as ReturnType).mockResolvedValueOnce({ + projectRoot, + config: { + default: 'dev', + apps: { + dev: { appId: 'app1', name: 'App', appHome: undefined, options: appOptionsRef.value }, + }, + }, + appKey: 'dev', + appId: 'app1', + }); + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [], + widgets: [], + scripts: [], + translations: [], + secrets: { secrets: { S1: 'cloud-secret' } }, + }); + + await pushCommand({ yes: true }); + + const payload = ( + (cloudModuleMock.submitEnvDocumentsPush as ReturnType).mock.calls[0] as [ + string, + string, + { secrets?: { secrets?: Record } }, + ] + )[2]; + expect(payload.secrets?.secrets).toEqual({}); + }); }); diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 2d65a6d..57474b5 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -13,7 +13,6 @@ import { computeEnvPullChanges, pruneStaleAssetEnvEntries, prepareEnvPushState, - warnIfMissingEnvFilesForPush, type CloudEnvState, type LocalEnvFiles, } from '../../src/core/envSync.js'; @@ -75,6 +74,8 @@ describe('envSync', () => { cloudAssets?: Array<{ fileName?: string; copyText?: string }>; configChanged: boolean; secretsChanged: boolean; + wouldClearConfig?: boolean; + wouldClearSecrets?: boolean; cloudConfig?: Record; localConfig?: Record; }>([ @@ -111,7 +112,7 @@ describe('envSync', () => { secretsChanged: false, }, { - name: 'skips push when env files are missing', + name: 'flags clear-all push when env files are missing but cloud has values', local: { envConfig: [], envSecrets: [], @@ -123,8 +124,10 @@ describe('envSync', () => { secrets: { secrets: { S1: 'SK1' } }, }, assets: [], - configChanged: false, - secretsChanged: false, + configChanged: true, + secretsChanged: true, + wouldClearConfig: true, + wouldClearSecrets: true, }, { name: 'shows full push config vs cloud including asset keys', @@ -205,12 +208,16 @@ describe('envSync', () => { cloudAssets, configChanged, secretsChanged, + wouldClearConfig = false, + wouldClearSecrets = false, cloudConfig, localConfig, }) => { const diff = buildEnvPushDiff(local, cloud, assets, cloudAssets); expect(diff.configChanged).toBe(configChanged); expect(diff.secretsChanged).toBe(secretsChanged); + expect(diff.wouldClearConfig).toBe(wouldClearConfig); + expect(diff.wouldClearSecrets).toBe(wouldClearSecrets); if (!configChanged && !secretsChanged) { expect(diff.local).toEqual({}); expect(diff.cloud).toEqual({}); @@ -380,7 +387,6 @@ describe('envSync', () => { config: { envVariables: { assets: 'https://cdn.example.com/', E1: 'EV1' } }, }, assetFileNames: [], - warn: () => {}, }); expect(state.diff.configChanged).toBe(true); @@ -398,7 +404,6 @@ describe('envSync', () => { projectRoot: tmpDir, cloudEnv: { config: { envVariables: { E1: 'EV1' } } }, assetFileNames: ['img1.png'], - warn: () => {}, }); const envConfigBeforeConfirm = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); @@ -426,25 +431,4 @@ describe('envSync', () => { expect(envConfig).toContain('logo_png=logo.png'); expect(envConfig).toContain('E1=EV1'); }); - - it('warnIfMissingEnvFilesForPush warns when cloud has values but local files are missing', () => { - const warnings: string[] = []; - warnIfMissingEnvFilesForPush( - { - envConfig: [], - envSecrets: [], - envConfigPresent: false, - envSecretsPresent: false, - }, - { - config: { envVariables: { E1: 'EV1' } }, - secrets: { secrets: { S1: 'SK1' } }, - }, - [], - (message) => warnings.push(message) - ); - expect(warnings).toHaveLength(2); - expect(warnings[0]).toContain('.env.config is missing'); - expect(warnings[1]).toContain('.env.secrets is missing'); - }); }); From dd31d2ffb0b524761a94e636279d1889dc803493 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Thu, 18 Jun 2026 00:31:37 +0500 Subject: [PATCH 08/10] feat(env): alias-scoped env sync for push/pull - resolve base vs .env.*. by default alias + file presence - non-default pull creates scoped files; never stomps base - missing env file skips push side; empty file wipes with confirm - pull asset keys to resolved config path - wire push/pull/release + docs/tests --- README.md | 12 ++ docs/Env-config-aliases.md | 114 ++++++------------- src/commands/pull.ts | 15 ++- src/commands/push.ts | 15 ++- src/commands/release.ts | 4 +- src/core/envConfig.ts | 11 ++ src/core/envSync.ts | 141 ++++++++++++++++++----- tests/commands/pushPull.test.ts | 39 +++++-- tests/core/envSync.test.ts | 192 +++++++++++++++++++++++++++----- 9 files changed, 384 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index e4f3799..e7e540c 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,18 @@ ensemble update 3. Run `ensemble push` to sync your local app (screens, widgets, scripts, etc.) with the cloud 4. Optionally run `ensemble pull` to refresh local artifacts from the cloud when other collaborators change them +### Environment files + +Config and secrets sync on `push`, `pull`, and `release use`: + +- **Base**: `.env.config`, `.env.secrets` (shared defaults) +- **Per-alias** (non-default alias, or when both scoped files exist): `.env.config.`, `.env.secrets.` +- Default alias with only base files uses the base pair; other aliases never overwrite base on pull +- **Missing** local file → that side skipped on push (no cloud wipe) +- **Empty** local file → wipe cloud keys on that side (with confirmation) + +Details: [docs/Env-config-aliases.md](docs/Env-config-aliases.md). + ### Versions / releases (snapshots) You can save and use snapshots of your app state in the cloud: diff --git a/docs/Env-config-aliases.md b/docs/Env-config-aliases.md index 61e9b1d..47962fa 100644 --- a/docs/Env-config-aliases.md +++ b/docs/Env-config-aliases.md @@ -1,6 +1,6 @@ # Environment config + secrets files (`.env.config` / `.env.secrets` + per-alias overrides) -This document proposes an environment-variable architecture for the Ensemble CLI that supports **multiple app environments** (or “targets”) cleanly and safely. +This document describes the environment-variable architecture used by the Ensemble CLI for **multiple app environments** (or “targets”). The chosen approach is: @@ -67,97 +67,68 @@ api_url=https://prod.ensemble.com --- -## Resolution / precedence rules +## Resolution rules -When running a command for a given alias, apply the same rules to **config** and **secrets**: +For the active alias (`--app` or `ensemble.config.json` → `default`): -- Config: - 1. Read `.env.config` if present (base defaults). - 2. Read `.env.config.` if present (alias overrides). - 3. Merge by key where **alias overrides win**. -- Secrets: - 1. Read `.env.secrets` if present (base defaults). - 2. Read `.env.secrets.` if present (alias overrides). - 3. Merge by key where **alias overrides win**. +| Situation | Files used | +|-----------|------------| +| Alias is **default** and only base files exist | `.env.config` + `.env.secrets` | +| Alias is **not default** | `.env.config.` + `.env.secrets.` (created on pull if missing) | +| Alias has **both** scoped files (any alias) | scoped pair wins over base | -If only `.env.config` exists, behavior matches today (backwards compatible). Secrets files are additive and optional. +No mixing across tiers. Config and secrets always come from the same tier. -### Why this precedence? - -- Shared values (common across envs) live in one place. -- Environment-specific values override without duplicating the whole file. +Pulling a non-default alias (e.g. `ensemble pull --app uat`) writes cloud env into `.env.config.uat` / `.env.secrets.uat` and leaves base files untouched. --- -## CLI behavior (proposed) - -### Reading env config +## CLI behavior -Commands that need env config should use the **effective env config** for the selected `--app` alias. +### Reading env files -- If `--app` is omitted, treat it as `default` (existing behavior). -- If `.env.config.` is missing, fall back to `.env.config` only. +Commands use the resolved pair for the selected `--app` alias (see resolution rules above). -### Pushing env variables +`--app` is optional and defaults to `ensemble.config.json` → `default`. -If the CLI supports pushing env vars to the cloud, it should be **explicitly scoped**: +### Missing vs empty (push) -- `ensemble push --app prod` may only push the **prod effective env config**. -- It must never push dev values to prod unless the user explicitly made them prod values (via `.env.config.prod` or identical base defaults). +| Local state | Push behavior | +|-------------|---------------| +| File **missing** | Ignored — no env push for that side, no cloud wipe | +| File **present, empty** | Wipe — warn + `[y/N]` before deleting all cloud keys on that side | -Recommended sync semantics: +### Pushing env variables -- Default: **upsert/patch** (add/update keys present in local effective env config). -- Optional: `--delete-missing` (dangerous) to remove remote keys not present locally. -- Optional: `--dry-run` to show changes without applying. +- `ensemble push --app ` pushes the **effective** env for that alias. +- Config and secrets are pushed independently (missing file → that side skipped). ### Pulling env variables -Similarly, pulling should be scoped: +- `ensemble pull --app ` writes cloud env into the scoped target file when in scoped mode (`.env.config.` / `.env.secrets.`), leaving the base file untouched. +- In legacy mode, pull continues to write `.env.config` / `.env.secrets`. -- `ensemble pull --app prod` should update **only** `.env.config.prod` (or optionally print a diff). -- Avoid writing prod keys into `.env.config` unless explicitly requested. +### Release use + +- `ensemble release use` restores snapshot config into the same write target as pull (scoped or base). --- ## Asset-generated keys and `.env.config` -Today, the CLI “upserts” `.env.config` to ensure asset-related keys exist after: +The CLI upserts `.env.config` for asset-related keys after: - `ensemble add asset` - `ensemble push` (asset upload) -- `ensemble pull` (asset sync) - -This design proposes: - -- Keep `.env.config` as a base defaults file for users. -- Write **asset-generated keys into the alias file** by default (because assets are associated with a specific app target). -Suggested split: - -- `.env.config`: user-managed shared defaults (checked in or not—team choice) -- `.env.config.`: app-target-specific values, including: - - `assets=` for that target - - any cloud-provided asset usage env keys for that target - -Backwards-compatibility note: - -- If alias files are not in use yet, continue writing to `.env.config` as today. -- Once alias files exist (or a new setting/flag opts into alias-mode), write to alias files. +Pull writes asset env keys (`assets=`, per-asset keys) into the resolved config file for the active alias (base or scoped). `ensemble add asset` still upserts the base `.env.config`. --- -## Safety and production protections - -To reduce “oops pushed dev to prod” failures: +## Safety -- **Require explicit target** for sensitive operations (recommended UX): - - For example, pushing env vars could require `--app` when multiple apps exist in `ensemble.config.json`. -- **Stronger confirmations** for production-like aliases (e.g. `prod`, `production`): - - Show a diff summary - - Require a typed confirmation or `--yes` -- **Never default to destructive deletes**: - - `--delete-missing` must be opt-in. +- **Never default to destructive deletes** except when a local env file exists but is empty (explicit wipe semantics above). +- **`--delete-missing`** is not implemented; local-only keys are not auto-deleted from cloud on push. --- @@ -181,14 +152,6 @@ At minimum, consider adding these to `.gitignore`: If you _do_ want to commit alias files for non-secret config, use a more selective ignore pattern or separate “public” vs “secret” configs. -### CLI handling expectations for secrets - -If/when the CLI reads or syncs secrets: - -- Never print secret values in logs (even in `--verbose`). -- Prefer diff output that only shows keys changed (and counts), not values. -- Consider stronger confirmations / restrictions for production aliases. - --- ## Examples @@ -233,16 +196,11 @@ assets=https://assets.prod.ensemble.com/ --- -## Migration plan (incremental) +## Migration plan -1. **Introduce alias file support** in read-paths: - - merge base + alias override (alias wins) -2. **Introduce alias-aware write-paths**: - - write generated keys (assets) into `.env.config.` when applicable -3. **Add env push/pull commands or flags** (if desired): - - ensure all operations are scoped to `--app ` -4. **Add guardrails**: - - diffs, confirmations for prod, optional delete-missing +1. Single-app projects: no change — keep using `.env.config` / `.env.secrets`. +2. Multi-app projects: add `.env.config.` / `.env.secrets.` for per-target overrides; shared defaults stay in the base files. +3. Existing single-app repos can opt in early by creating a scoped file (e.g. `.env.config.dev`). --- diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 9271a2a..e60908a 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -21,9 +21,9 @@ import { type RootManifest } from '../core/manifest.js'; import { writeVerboseJson } from '../core/debugFiles.js'; import { computePullPlan, type PullSummary } from '../core/sync.js'; import { applyCloudAssetsToFs, buildEnvConfigForCloudAssets } from '../core/pullAssets.js'; +import { upsertEnvFile } from '../core/envConfig.js'; import { applyCloudEnvToFs, readProjectEnvFiles } from '../core/envSync.js'; import { ui } from '../core/ui.js'; -import { upsertEnvConfig } from '../core/envConfig.js'; export interface PullOptions { verbose?: boolean; @@ -268,7 +268,7 @@ export async function pullCommand(options: PullOptions = {}): Promise { localFiles, manifestExisting, enabledByProp, - localEnv: await readProjectEnvFiles(projectRoot), + localEnv: await readProjectEnvFiles(projectRoot, appKey, config.default), }); if (plan.allArtifactsMatch && plan.manifestMatch) { @@ -364,14 +364,15 @@ export async function pullCommand(options: PullOptions = {}): Promise { } } - // Always (best-effort) update .env.config for assets so ${env.assets}${env.} references work after pull. + // Always (best-effort) update env config for assets so ${env.assets}${env.} references work after pull. + const envLayout = await readProjectEnvFiles(projectRoot, appKey, config.default); const envResult = buildEnvConfigForCloudAssets(cloudApp.assets); if (envResult.entries.length > 0) { - await upsertEnvConfig(projectRoot, envResult.entries); + await upsertEnvFile(projectRoot, envLayout.configWriteFile, envResult.entries); } if (envResult.failures.length > 0) { ui.warn( - `Some assets had invalid metadata and may be missing from .env.config (${envResult.failures.length}).` + `Some assets had invalid metadata and may be missing from ${envLayout.configWriteFile} (${envResult.failures.length}).` ); } @@ -385,7 +386,9 @@ export async function pullCommand(options: PullOptions = {}): Promise { .map((asset) => asset.fileName) .filter( (fileName): fileName is string => typeof fileName === 'string' && fileName.length > 0 - ) + ), + appKey, + config.default ); }); diff --git a/src/commands/push.ts b/src/commands/push.ts index d731526..e10df17 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -341,6 +341,8 @@ export async function pushCommand(options: PushOptions = {}): Promise { const assetFileNames = data.assetFiles ?? []; const envPush = await prepareEnvPushState({ projectRoot: root, + appKey, + defaultAppKey: config.default, cloudEnv: { config: cloudApp.config, secrets: cloudApp.secrets }, assetFileNames, cloudAssets: cloudApp.assets, @@ -350,6 +352,7 @@ export async function pushCommand(options: PushOptions = {}): Promise { pushConfigDto, pushSecretsDto: localSecretsDto, pendingLocalEnvConfigWrite, + localEnv: envLocal, } = envPush; const envConfigChanged = envPushDiff.configChanged; const envSecretsChanged = envPushDiff.secretsChanged; @@ -415,7 +418,7 @@ export async function pushCommand(options: PushOptions = {}): Promise { } if (wouldClearConfig || wouldClearSecrets) { ui.warn( - 'Push would delete all cloud env/secrets (.env.config and/or .env.secrets missing or empty).' + 'Push would delete all cloud env/secrets (local env file present but empty).' ); } return; @@ -428,11 +431,11 @@ export async function pushCommand(options: PushOptions = {}): Promise { } if (envConfigChanged) { // eslint-disable-next-line no-console - console.log(' env:\n ✏️ modified .env.config'); + console.log(` env:\n ✏️ modified ${envLocal.configWriteFile}`); } if (envSecretsChanged) { // eslint-disable-next-line no-console - console.log(' env:\n ✏️ modified .env.secrets'); + console.log(` env:\n ✏️ modified ${envLocal.secretsWriteFile}`); } const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); @@ -456,8 +459,8 @@ export async function pushCommand(options: PushOptions = {}): Promise { if (wouldClearConfig || wouldClearSecrets) { const targets = [ - wouldClearConfig && 'env variables (.env.config)', - wouldClearSecrets && 'secrets (.env.secrets)', + wouldClearConfig && `env variables (${envLocal.configWriteFile})`, + wouldClearSecrets && `secrets (${envLocal.secretsWriteFile})`, ].filter((t): t is string => Boolean(t)); ui.warn(`Pushing will delete all cloud ${targets.join(' and ')}.`); @@ -545,7 +548,7 @@ export async function pushCommand(options: PushOptions = {}): Promise { try { if (pendingLocalEnvConfigWrite) { - await writeEnvFile(root, '.env.config', pendingLocalEnvConfigWrite); + await writeEnvFile(root, envLocal.configWriteFile, pendingLocalEnvConfigWrite); } if (yamlChangeTotal > 0 || assetsToUpload.length > 0 || assetsToArchive.length > 0) { diff --git a/src/commands/release.ts b/src/commands/release.ts index e20a3c7..6b325ec 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -110,7 +110,7 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): const appName = (appConfig.name as string | undefined) ?? 'App'; const appHome = appConfig.appHome as string | undefined; const localFiles = await collectAppFiles(root); - const localEnv = await readProjectEnvFiles(root); + const localEnv = await readProjectEnvFiles(root, appKey, config.default); const localConfig = buildConfigDtoFromEnvEntries(localEnv.envConfig); const localApp = buildDocumentsFromParsed(localFiles, appId, appName, appHome, undefined); const snapshot: CloudApp = { @@ -392,7 +392,7 @@ export async function releaseUseCommand(options: ReleaseUseOptions = {}): Promis }, }) ); - await applyReleaseConfigToFs(projectRoot, snapshot.config); + await applyReleaseConfigToFs(projectRoot, snapshot.config, appKey, config.default); ui.success( 'Local files updated to selected release. Run "ensemble push" to apply to the cloud.' ); diff --git a/src/core/envConfig.ts b/src/core/envConfig.ts index 5200a59..7b02ed3 100644 --- a/src/core/envConfig.ts +++ b/src/core/envConfig.ts @@ -7,6 +7,17 @@ export interface EnvEntry { overwrite?: boolean; } +export const ENV_CONFIG_BASE = '.env.config'; +export const ENV_SECRETS_BASE = '.env.secrets'; + +export function envConfigScopedFile(appKey: string): string { + return `${ENV_CONFIG_BASE}.${appKey}`; +} + +export function envSecretsScopedFile(appKey: string): string { + return `${ENV_SECRETS_BASE}.${appKey}`; +} + function parseEnvFile(raw: string): { lines: string[]; keyToLineIndex: Map; diff --git a/src/core/envSync.ts b/src/core/envSync.ts index 3c52ffa..29ef9c4 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -3,7 +3,11 @@ import path from 'node:path'; import type { ConfigDTO, SecretDTO } from './dto.js'; import { deriveAssetEnvKey, resolveAssetEnvKey } from './pullAssets.js'; import { + ENV_CONFIG_BASE, + ENV_SECRETS_BASE, + envConfigScopedFile, envFileExists, + envSecretsScopedFile, readEnvFile, upsertEnvFile, writeEnvFile, @@ -16,10 +20,22 @@ export interface CloudEnvState { } export interface LocalEnvFiles { + appKey: string; + useScoped: boolean; + configWriteFile: string; + secretsWriteFile: string; envConfig: EnvEntry[]; envSecrets: EnvEntry[]; + baseConfig: EnvEntry[]; + scopedConfig: EnvEntry[]; + baseSecrets: EnvEntry[]; + scopedSecrets: EnvEntry[]; envConfigPresent: boolean; envSecretsPresent: boolean; + baseConfigPresent: boolean; + scopedConfigPresent: boolean; + baseSecretsPresent: boolean; + scopedSecretsPresent: boolean; } export type CloudAssetEnvRef = { @@ -172,6 +188,7 @@ function wouldClearConfigOnPush( assetFileNames: string[], cloudAssets?: CloudAssetEnvRef[] ): boolean { + if (!localEnv.envConfigPresent) return false; const cloudNonAsset = configDtoToEnvEntries( stripAssetKeysFromConfigDto(cloudConfig, assetFileNames, cloudAssets) ); @@ -182,9 +199,9 @@ function wouldClearConfigOnPush( } function wouldClearSecretsOnPush(localEnv: LocalEnvFiles, cloudSecrets?: SecretDTO): boolean { + if (!localEnv.envSecretsPresent) return false; return ( - secretsDtoToEnvEntries(cloudSecrets).length > 0 && - (!localEnv.envSecretsPresent || localEnv.envSecrets.length === 0) + secretsDtoToEnvEntries(cloudSecrets).length > 0 && localEnv.envSecrets.length === 0 ); } @@ -242,16 +259,55 @@ export function buildPushConfigDto( return { envVariables }; } -export async function readProjectEnvFiles(projectRoot: string): Promise { - const [envConfigPresent, envSecretsPresent] = await Promise.all([ - envFileExists(projectRoot, '.env.config'), - envFileExists(projectRoot, '.env.secrets'), +export async function readProjectEnvFiles( + projectRoot: string, + appKey: string, + defaultAppKey: string +): Promise { + const scopedConfigFile = envConfigScopedFile(appKey); + const scopedSecretsFile = envSecretsScopedFile(appKey); + const [ + baseConfigPresent, + scopedConfigPresent, + baseSecretsPresent, + scopedSecretsPresent, + ] = await Promise.all([ + envFileExists(projectRoot, ENV_CONFIG_BASE), + envFileExists(projectRoot, scopedConfigFile), + envFileExists(projectRoot, ENV_SECRETS_BASE), + envFileExists(projectRoot, scopedSecretsFile), ]); + const scopedPairPresent = scopedConfigPresent && scopedSecretsPresent; + const useScoped = scopedPairPresent || appKey !== defaultAppKey; + const configWriteFile = useScoped ? scopedConfigFile : ENV_CONFIG_BASE; + const secretsWriteFile = useScoped ? scopedSecretsFile : ENV_SECRETS_BASE; + + const baseConfig = baseConfigPresent ? await readEnvFile(projectRoot, ENV_CONFIG_BASE) : []; + const scopedConfig = scopedConfigPresent + ? await readEnvFile(projectRoot, scopedConfigFile) + : []; + const baseSecrets = baseSecretsPresent ? await readEnvFile(projectRoot, ENV_SECRETS_BASE) : []; + const scopedSecrets = scopedSecretsPresent + ? await readEnvFile(projectRoot, scopedSecretsFile) + : []; + return { - envConfigPresent, - envSecretsPresent, - envConfig: envConfigPresent ? await readEnvFile(projectRoot, '.env.config') : [], - envSecrets: envSecretsPresent ? await readEnvFile(projectRoot, '.env.secrets') : [], + appKey, + useScoped, + configWriteFile, + secretsWriteFile, + baseConfig, + scopedConfig, + baseSecrets, + scopedSecrets, + envConfig: useScoped ? scopedConfig : baseConfig, + envSecrets: useScoped ? scopedSecrets : baseSecrets, + baseConfigPresent, + scopedConfigPresent, + baseSecretsPresent, + scopedSecretsPresent, + envConfigPresent: useScoped ? scopedConfigPresent : baseConfigPresent, + envSecretsPresent: useScoped ? scopedSecretsPresent : baseSecretsPresent, }; } @@ -352,7 +408,7 @@ export interface EnvPullChanges { configMatch: boolean; secretsMatch: boolean; match: boolean; - filesToUpdate: Array<'.env.config' | '.env.secrets'>; + filesToUpdate: string[]; } export function computeEnvPullChanges( @@ -366,12 +422,13 @@ export function computeEnvPullChanges( const configMatch = envConfigEntriesMatchCloud( localEnv?.envConfig ?? [], cloudConfig, - assetFileNames + assetFileNames, + cloudAssets ); const secretsMatch = envSecretsEntriesMatchCloud(localEnv?.envSecrets ?? [], cloudSecrets); - const filesToUpdate: EnvPullChanges['filesToUpdate'] = []; - if (!configMatch) filesToUpdate.push('.env.config'); - if (!secretsMatch) filesToUpdate.push('.env.secrets'); + const filesToUpdate: string[] = []; + if (!configMatch) filesToUpdate.push(localEnv?.configWriteFile ?? ENV_CONFIG_BASE); + if (!secretsMatch) filesToUpdate.push(localEnv?.secretsWriteFile ?? ENV_SECRETS_BASE); return { assetFileNames, configMatch, @@ -391,15 +448,31 @@ export interface EnvPushState { export async function prepareEnvPushState(params: { projectRoot: string; + appKey: string; + defaultAppKey: string; cloudEnv: CloudEnvState; assetFileNames: string[]; cloudAssets?: CloudAssetEnvRef[]; }): Promise { - const localEnvRaw = await readProjectEnvFiles(params.projectRoot); - const prunedEnvConfig = localEnvRaw.envConfigPresent - ? pruneStaleAssetEnvEntries(localEnvRaw.envConfig, params.assetFileNames, params.cloudAssets) + const localEnvRaw = await readProjectEnvFiles( + params.projectRoot, + params.appKey, + params.defaultAppKey + ); + const prunedConfigSource = localEnvRaw.envConfigPresent + ? pruneStaleAssetEnvEntries( + localEnvRaw.envConfig, + params.assetFileNames, + params.cloudAssets + ) : localEnvRaw.envConfig; - const localEnv = { ...localEnvRaw, envConfig: prunedEnvConfig }; + const localEnv: LocalEnvFiles = { + ...localEnvRaw, + envConfig: prunedConfigSource, + ...(localEnvRaw.useScoped + ? { scopedConfig: prunedConfigSource } + : { baseConfig: prunedConfigSource }), + }; const diff = buildEnvPushDiff( localEnv, params.cloudEnv, @@ -413,37 +486,49 @@ export async function prepareEnvPushState(params: { pushConfigDto: diff.local.config, pushSecretsDto: diff.local.secrets, ...(localEnvRaw.envConfigPresent && - !entriesEqual(prunedEnvConfig, localEnvRaw.envConfig) && { - pendingLocalEnvConfigWrite: prunedEnvConfig, + !entriesEqual(prunedConfigSource, localEnvRaw.envConfig) && { + pendingLocalEnvConfigWrite: prunedConfigSource, }), }; } export async function applyReleaseConfigToFs( projectRoot: string, - config: ConfigDTO | undefined + config: ConfigDTO | undefined, + appKey: string, + defaultAppKey: string ): Promise { const configEntries = configDtoToEnvEntries(config); - if (configEntries.length > 0) await writeEnvFile(projectRoot, '.env.config', configEntries); + if (configEntries.length === 0) return; + const { configWriteFile } = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); + await writeEnvFile(projectRoot, configWriteFile, configEntries); } export async function applyCloudEnvToFs( projectRoot: string, cloudEnv: CloudEnvState, - assetFileNames: string[] = [] + assetFileNames: string[] = [], + appKey = 'default', + defaultAppKey = appKey ): Promise { + const layout = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); + const configWriteFile = layout.configWriteFile; + const secretsWriteFile = layout.secretsWriteFile; + const assetKeys = collectAssetEnvKeys(assetFileNames); const cloudVars = cloudEnv.config?.envVariables ?? {}; const assetEntries = [...assetKeys] .map((key) => ({ key, value: cloudVars[key] })) .filter((entry): entry is EnvEntry => typeof entry.value === 'string'); - if (assetEntries.length > 0) await upsertEnvFile(projectRoot, '.env.config', assetEntries); + if (assetEntries.length > 0) { + await upsertEnvFile(projectRoot, configWriteFile, assetEntries); + } - const existing = await readEnvFile(projectRoot, '.env.config'); + const existing = await readEnvFile(projectRoot, configWriteFile); const keptAssetEntries = existing.filter((entry) => assetKeys.has(entry.key)); const nonAssetEntries = configDtoToEnvEntries( stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames) ); - await writeEnvFile(projectRoot, '.env.config', [...keptAssetEntries, ...nonAssetEntries]); - await writeEnvFile(projectRoot, '.env.secrets', secretsDtoToEnvEntries(cloudEnv.secrets)); + await writeEnvFile(projectRoot, configWriteFile, [...keptAssetEntries, ...nonAssetEntries]); + await writeEnvFile(projectRoot, secretsWriteFile, secretsDtoToEnvEntries(cloudEnv.secrets)); } diff --git a/tests/commands/pushPull.test.ts b/tests/commands/pushPull.test.ts index cac23fd..8bfe090 100644 --- a/tests/commands/pushPull.test.ts +++ b/tests/commands/pushPull.test.ts @@ -1041,16 +1041,8 @@ describe('push/pull integration (commands)', () => { expect(payload.secrets?.secrets?.S1).toBe('local-secret'); }); - it.each([ - ['deleted', async () => {}], - [ - 'empty', - async () => { - await fs.writeFile(path.join(projectRoot, '.env.secrets'), '', 'utf8'); - }, - ], - ])('push clears cloud secrets when .env.secrets is %s', async (_label, setupSecrets) => { - await setupSecrets(); + it('push clears cloud secrets when .env.secrets is empty', async () => { + await fs.writeFile(path.join(projectRoot, '.env.secrets'), '', 'utf8'); (resolveAppContext as ReturnType).mockResolvedValueOnce({ projectRoot, config: { @@ -1083,4 +1075,31 @@ describe('push/pull integration (commands)', () => { )[2]; expect(payload.secrets?.secrets).toEqual({}); }); + + it('push skips secrets when .env.secrets is missing', async () => { + (resolveAppContext as ReturnType).mockResolvedValueOnce({ + projectRoot, + config: { + default: 'dev', + apps: { + dev: { appId: 'app1', name: 'App', appHome: undefined, options: appOptionsRef.value }, + }, + }, + appKey: 'dev', + appId: 'app1', + }); + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [], + widgets: [], + scripts: [], + translations: [], + secrets: { secrets: { S1: 'cloud-secret' } }, + }); + + await pushCommand({ yes: true }); + + expect(cloudModuleMock.submitEnvDocumentsPush).not.toHaveBeenCalled(); + }); }); diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 57474b5..92cf6aa 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -13,22 +13,43 @@ import { computeEnvPullChanges, pruneStaleAssetEnvEntries, prepareEnvPushState, + readProjectEnvFiles, type CloudEnvState, type LocalEnvFiles, } from '../../src/core/envSync.js'; +import { envConfigScopedFile, envSecretsScopedFile } from '../../src/core/envConfig.js'; -function localEnvFromParts( - configEntries: EnvEntry[], - assetEntries: EnvEntry[] = [] -): LocalEnvFiles { +function localEnv(overrides: Partial & Pick): LocalEnvFiles { + const baseConfig = overrides.baseConfig ?? overrides.envConfig; return { - envConfig: [...configEntries, ...assetEntries], + appKey: 'default', + useScoped: false, + configWriteFile: '.env.config', + secretsWriteFile: '.env.secrets', envSecrets: [], + baseConfig, + scopedConfig: [], + baseSecrets: [], + scopedSecrets: [], envConfigPresent: true, envSecretsPresent: false, + baseConfigPresent: true, + scopedConfigPresent: false, + baseSecretsPresent: false, + scopedSecretsPresent: false, + ...overrides, + envConfig: overrides.envConfig, }; } +function localEnvFromParts( + configEntries: EnvEntry[], + assetEntries: EnvEntry[] = [] +): LocalEnvFiles { + const envConfig = [...configEntries, ...assetEntries]; + return localEnv({ envConfig, baseConfig: envConfig }); +} + describe('envSync', () => { let tmpDir: string; @@ -53,7 +74,8 @@ describe('envSync', () => { config: { envVariables: { API_URL: 'https://api.example.com', E1: 'EV1' } }, secrets: { secrets: { S1: 'secret-value' } }, }, - ['logo.png'] + ['logo.png'], + 'default' ); const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); @@ -81,12 +103,11 @@ describe('envSync', () => { }>([ { name: 'detects config and secrets changes', - local: { + local: localEnv({ envConfig: [{ key: 'E1', value: 'local' }], envSecrets: [{ key: 'S1', value: 'local' }], - envConfigPresent: true, envSecretsPresent: true, - }, + }), cloud: { config: { envVariables: { E1: 'cloud' } }, secrets: { secrets: { S1: 'cloud' } }, @@ -97,12 +118,11 @@ describe('envSync', () => { }, { name: 'omits snapshots when in sync', - local: { + local: localEnv({ envConfig: [{ key: 'E1', value: 'EV1' }], envSecrets: [{ key: 'S1', value: 'SK1' }], - envConfigPresent: true, envSecretsPresent: true, - }, + }), cloud: { config: { envVariables: { E1: 'EV1' } }, secrets: { secrets: { S1: 'SK1' } }, @@ -112,13 +132,33 @@ describe('envSync', () => { secretsChanged: false, }, { - name: 'flags clear-all push when env files are missing but cloud has values', - local: { + name: 'skips env push when env files are missing but cloud has values', + local: localEnv({ envConfig: [], - envSecrets: [], + baseConfig: [], envConfigPresent: false, envSecretsPresent: false, + baseConfigPresent: false, + }), + cloud: { + config: { envVariables: { E1: 'EV1' } }, + secrets: { secrets: { S1: 'SK1' } }, }, + assets: [], + configChanged: false, + secretsChanged: false, + wouldClearConfig: false, + wouldClearSecrets: false, + }, + { + name: 'wipes cloud when env files are present but empty', + local: localEnv({ + envConfig: [], + baseConfig: [], + envSecrets: [], + envConfigPresent: true, + envSecretsPresent: true, + }), cloud: { config: { envVariables: { E1: 'EV1' } }, secrets: { secrets: { S1: 'SK1' } }, @@ -131,16 +171,13 @@ describe('envSync', () => { }, { name: 'shows full push config vs cloud including asset keys', - local: { + local: localEnv({ envConfig: [ { key: 'E1', value: 'EV11' }, { key: 'assets', value: 'https://cdn.example.com/' }, { key: 'logo_png', value: 'logo.png?local=abc' }, ], - envSecrets: [], - envConfigPresent: true, - envSecretsPresent: true, - }, + }), cloud: { config: { envVariables: { @@ -168,12 +205,9 @@ describe('envSync', () => { }, { name: 'flags configChanged when only stale asset env keys differ from cloud', - local: { + local: localEnv({ envConfig: [{ key: 'E1', value: 'EV1' }], - envSecrets: [], - envConfigPresent: true, - envSecretsPresent: true, - }, + }), cloud: { config: { envVariables: { @@ -352,15 +386,14 @@ describe('envSync', () => { it('computeEnvPullChanges flags config mismatch including missing asset env keys', () => { const result = computeEnvPullChanges( - { + localEnv({ envConfig: [ { key: 'assets', value: 'https://cdn.example.com/' }, { key: 'E1', value: 'EV111' }, ], envSecrets: [{ key: 'S1', value: 'SK1' }], - envConfigPresent: true, envSecretsPresent: true, - }, + }), { envVariables: { assets: 'https://cdn.example.com/', @@ -378,11 +411,37 @@ describe('envSync', () => { expect(result.filesToUpdate).toEqual(['.env.config']); }); + it('computeEnvPullChanges flags missing asset keys in scoped alias config files', () => { + const result = computeEnvPullChanges( + localEnv({ + useScoped: true, + configWriteFile: '.env.config.uat', + envConfig: [{ key: 'E2', value: 'EK2' }], + envConfigPresent: true, + }), + { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?token=abc', + E2: 'EK2', + }, + }, + undefined, + ['logo.png'], + [{ fileName: 'logo.png' }] + ); + + expect(result.configMatch).toBe(false); + expect(result.filesToUpdate).toEqual(['.env.config.uat']); + }); + it('prepareEnvPushState omits cloud asset keys when no local asset files exist', async () => { await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=EV11\n', 'utf8'); const state = await prepareEnvPushState({ projectRoot: tmpDir, + appKey: 'default', + defaultAppKey: 'default', cloudEnv: { config: { envVariables: { assets: 'https://cdn.example.com/', E1: 'EV1' } }, }, @@ -402,6 +461,8 @@ describe('envSync', () => { const state = await prepareEnvPushState({ projectRoot: tmpDir, + appKey: 'default', + defaultAppKey: 'default', cloudEnv: { config: { envVariables: { E1: 'EV1' } } }, assetFileNames: ['img1.png'], }); @@ -424,11 +485,84 @@ describe('envSync', () => { logo_png: 'logo.png', E1: 'EV1', }, - }); + }, 'default', 'default'); const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); expect(envConfig).toContain('assets=https://cdn.example.com/'); expect(envConfig).toContain('logo_png=logo.png'); expect(envConfig).toContain('E1=EV1'); }); + + it('readProjectEnvFiles uses scoped pair when both alias files exist', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\nE2=shared\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, envConfigScopedFile('uat')), 'E1=uat\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, envSecretsScopedFile('uat')), 'S1=uat\n', 'utf8'); + + const env = await readProjectEnvFiles(tmpDir, 'uat', 'dev'); + expect(env.useScoped).toBe(true); + expect(env.configWriteFile).toBe('.env.config.uat'); + expect(env.secretsWriteFile).toBe('.env.secrets.uat'); + expect(env.envConfig).toEqual([{ key: 'E1', value: 'uat' }]); + expect(env.envSecrets).toEqual([{ key: 'S1', value: 'uat' }]); + }); + + it('readProjectEnvFiles uses base for default alias when only base files exist', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); + + const env = await readProjectEnvFiles(tmpDir, 'dev', 'dev'); + expect(env.useScoped).toBe(false); + expect(env.configWriteFile).toBe('.env.config'); + expect(env.envConfig).toEqual([{ key: 'E1', value: 'base' }]); + expect(env.envSecrets).toEqual([{ key: 'S1', value: 'base' }]); + }); + + it('readProjectEnvFiles targets scoped paths for non-default alias even before files exist', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); + + const env = await readProjectEnvFiles(tmpDir, 'uat', 'dev'); + expect(env.useScoped).toBe(true); + expect(env.configWriteFile).toBe('.env.config.uat'); + expect(env.secretsWriteFile).toBe('.env.secrets.uat'); + expect(env.envConfig).toEqual([]); + expect(env.envSecrets).toEqual([]); + expect(env.envConfigPresent).toBe(false); + expect(env.envSecretsPresent).toBe(false); + }); + + it('applyCloudEnvToFs creates scoped files for non-default alias without touching base', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); + await applyCloudEnvToFs( + tmpDir, + { + config: { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png', + E1: 'uat', + E2: 'new', + }, + }, + secrets: { secrets: { S1: 'sk' } }, + }, + ['logo.png'], + 'uat', + 'dev' + ); + + const baseConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + const baseSecrets = await fs.readFile(path.join(tmpDir, '.env.secrets'), 'utf8'); + const scoped = await fs.readFile(path.join(tmpDir, envConfigScopedFile('uat')), 'utf8'); + const secrets = await fs.readFile(path.join(tmpDir, envSecretsScopedFile('uat')), 'utf8'); + expect(baseConfig).toContain('E1=base'); + expect(baseSecrets).toContain('S1=base'); + expect(scoped).toContain('E1=uat'); + expect(scoped).toContain('E2=new'); + expect(scoped).toContain('assets=https://cdn.example.com/'); + expect(scoped).toContain('logo_png=logo.png'); + expect(secrets).toContain('S1=sk'); + }); }); From d71dae8dc2e43928a5e0b531c0f0657cb6e43dd6 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Thu, 18 Jun 2026 00:32:03 +0500 Subject: [PATCH 09/10] fix(release): honor --app on subcommands - Commander ate --app when parent+child both declared it - read --app from release parent opts in create/list/use - align Firestore version id with GCS snapshot path - alias-aware success/list hints for non-default apps --- docs/Env-config-aliases.md | 16 +++++++-------- src/cloud/firestoreClient.ts | 4 +++- src/commands/push.ts | 4 +--- src/commands/release.ts | 9 ++++++-- src/core/envSync.ts | 32 +++++++++-------------------- src/index.ts | 25 +++++++++++++--------- tests/cloud/firestoreClient.test.ts | 29 ++++++++++++++++++++++++++ tests/commands/release.test.ts | 22 ++++++++++++++++++++ tests/core/envSync.test.ts | 21 ++++++++++++------- 9 files changed, 109 insertions(+), 53 deletions(-) diff --git a/docs/Env-config-aliases.md b/docs/Env-config-aliases.md index 47962fa..642928c 100644 --- a/docs/Env-config-aliases.md +++ b/docs/Env-config-aliases.md @@ -71,11 +71,11 @@ api_url=https://prod.ensemble.com For the active alias (`--app` or `ensemble.config.json` → `default`): -| Situation | Files used | -|-----------|------------| -| Alias is **default** and only base files exist | `.env.config` + `.env.secrets` | -| Alias is **not default** | `.env.config.` + `.env.secrets.` (created on pull if missing) | -| Alias has **both** scoped files (any alias) | scoped pair wins over base | +| Situation | Files used | +| ---------------------------------------------- | --------------------------------------------------------------------------- | +| Alias is **default** and only base files exist | `.env.config` + `.env.secrets` | +| Alias is **not default** | `.env.config.` + `.env.secrets.` (created on pull if missing) | +| Alias has **both** scoped files (any alias) | scoped pair wins over base | No mixing across tiers. Config and secrets always come from the same tier. @@ -93,9 +93,9 @@ Commands use the resolved pair for the selected `--app` alias (see resolution ru ### Missing vs empty (push) -| Local state | Push behavior | -|-------------|---------------| -| File **missing** | Ignored — no env push for that side, no cloud wipe | +| Local state | Push behavior | +| ----------------------- | ----------------------------------------------------------------- | +| File **missing** | Ignored — no env push for that side, no cloud wipe | | File **present, empty** | Wipe — warn + `[y/N]` before deleting all cloud keys on that side | ### Pushing env variables diff --git a/src/cloud/firestoreClient.ts b/src/cloud/firestoreClient.ts index 72d309b..f6fe37e 100644 --- a/src/cloud/firestoreClient.ts +++ b/src/cloud/firestoreClient.ts @@ -216,6 +216,8 @@ export interface VersionMetadata { export type VersionDoc = VersionMetadata; export interface CreateVersionParams { + /** When set, used as the Firestore version document id (and should match the storage object name). */ + id?: string; message: string; createdAt: string; createdBy: { name: string; email?: string; id: string }; @@ -1434,7 +1436,7 @@ export async function createVersion( options?: FirestoreClientOptions ): Promise<{ id: string }> { const project = getEnsembleFirebaseProject(); - const versionId = generateVersionId(); + const versionId = params.id ?? generateVersionId(); const parent = `projects/${project}/databases/(default)/documents/apps/${appId}`; const url = `https://firestore.googleapis.com/v1/${parent}/versions?documentId=${encodeURIComponent(versionId)}`; diff --git a/src/commands/push.ts b/src/commands/push.ts index e10df17..601fb5c 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -417,9 +417,7 @@ export async function pushCommand(options: PushOptions = {}): Promise { ui.note('Env file changes would also be pushed (.env.config / .env.secrets).'); } if (wouldClearConfig || wouldClearSecrets) { - ui.warn( - 'Push would delete all cloud env/secrets (local env file present but empty).' - ); + ui.warn('Push would delete all cloud env/secrets (local env file present but empty).'); } return; } diff --git a/src/commands/release.ts b/src/commands/release.ts index 6b325ec..ea0a987 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -63,6 +63,10 @@ function formatReleaseLine(index: number, v: VersionDoc): string { return `${index + 1}. ${date} — ${msg} [hash: ${v.id}]`; } +function releaseUseHint(appKey: string, defaultAppKey: string): string { + return appKey === defaultAppKey ? 'ensemble release use' : `ensemble release use --app ${appKey}`; +} + export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): Promise { const root = process.cwd(); const { config, appKey, appId } = await resolveAppContext(options.appKey); @@ -140,6 +144,7 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): appId, idToken, { + id: versionId, message: message.trim(), createdAt: now.toISOString(), createdBy: { name: session.name ?? 'User', id: userId }, @@ -148,7 +153,7 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): }, firestoreOptions ); - ui.success('Release saved. Run "ensemble release use" to use it.'); + ui.success(`Release saved. Run "${releaseUseHint(appKey, config.default)}" to use it.`); } catch (err) { if (err instanceof FirestoreClientError) { ui.error(err.message); @@ -242,7 +247,7 @@ export async function releaseListCommand(options: ReleaseListOptions = {}): Prom return; } - ui.heading(`Releases for app "${appConfig.name ?? appKey}":`); + ui.heading(`Releases for "${appKey}":`); versions.forEach((v, idx) => { ui.note(formatReleaseLine(idx, v)); }); diff --git a/src/core/envSync.ts b/src/core/envSync.ts index 29ef9c4..09f5b3a 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -200,9 +200,7 @@ function wouldClearConfigOnPush( function wouldClearSecretsOnPush(localEnv: LocalEnvFiles, cloudSecrets?: SecretDTO): boolean { if (!localEnv.envSecretsPresent) return false; - return ( - secretsDtoToEnvEntries(cloudSecrets).length > 0 && localEnv.envSecrets.length === 0 - ); + return secretsDtoToEnvEntries(cloudSecrets).length > 0 && localEnv.envSecrets.length === 0; } export function pruneStaleAssetEnvEntries( @@ -266,26 +264,20 @@ export async function readProjectEnvFiles( ): Promise { const scopedConfigFile = envConfigScopedFile(appKey); const scopedSecretsFile = envSecretsScopedFile(appKey); - const [ - baseConfigPresent, - scopedConfigPresent, - baseSecretsPresent, - scopedSecretsPresent, - ] = await Promise.all([ - envFileExists(projectRoot, ENV_CONFIG_BASE), - envFileExists(projectRoot, scopedConfigFile), - envFileExists(projectRoot, ENV_SECRETS_BASE), - envFileExists(projectRoot, scopedSecretsFile), - ]); + const [baseConfigPresent, scopedConfigPresent, baseSecretsPresent, scopedSecretsPresent] = + await Promise.all([ + envFileExists(projectRoot, ENV_CONFIG_BASE), + envFileExists(projectRoot, scopedConfigFile), + envFileExists(projectRoot, ENV_SECRETS_BASE), + envFileExists(projectRoot, scopedSecretsFile), + ]); const scopedPairPresent = scopedConfigPresent && scopedSecretsPresent; const useScoped = scopedPairPresent || appKey !== defaultAppKey; const configWriteFile = useScoped ? scopedConfigFile : ENV_CONFIG_BASE; const secretsWriteFile = useScoped ? scopedSecretsFile : ENV_SECRETS_BASE; const baseConfig = baseConfigPresent ? await readEnvFile(projectRoot, ENV_CONFIG_BASE) : []; - const scopedConfig = scopedConfigPresent - ? await readEnvFile(projectRoot, scopedConfigFile) - : []; + const scopedConfig = scopedConfigPresent ? await readEnvFile(projectRoot, scopedConfigFile) : []; const baseSecrets = baseSecretsPresent ? await readEnvFile(projectRoot, ENV_SECRETS_BASE) : []; const scopedSecrets = scopedSecretsPresent ? await readEnvFile(projectRoot, scopedSecretsFile) @@ -460,11 +452,7 @@ export async function prepareEnvPushState(params: { params.defaultAppKey ); const prunedConfigSource = localEnvRaw.envConfigPresent - ? pruneStaleAssetEnvEntries( - localEnvRaw.envConfig, - params.assetFileNames, - params.cloudAssets - ) + ? pruneStaleAssetEnvEntries(localEnvRaw.envConfig, params.assetFileNames, params.cloudAssets) : localEnvRaw.envConfig; const localEnv: LocalEnvFiles = { ...localEnvRaw, diff --git a/src/index.ts b/src/index.ts index 0428b82..abd87c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,11 @@ import { ui } from './core/ui.js'; const program = new Command(); +/** Commander stores --app on the release parent when subcommands also declare it; read parent opts. */ +function resolveReleaseAppKey(command: Command): string | undefined { + return command.parent?.opts()?.app as string | undefined; +} + program .name('ensemble') .description('Ensemble CLI for logging in and configuring Ensemble apps.') @@ -93,17 +98,19 @@ program const releaseCmd = program .command('release') .description('Manage releases (snapshots) of your app.') - .option('--app ', 'App alias to use (defaults to "default")'); + .option( + '--app ', + 'App alias (defaults to ensemble.config.json default; place before or after subcommand)' + ); releaseCmd .command('create') .description('Create a release (snapshot) from the current cloud state (no push required).') - .option('--app ', 'App alias to use (defaults to "default")') .option('-m, --message ', 'Release message (skips prompt)') .option('-y, --yes', 'Skip message prompt (use empty message)') - .action(async (options: { app?: string; message?: string; yes?: boolean }) => { + .action(async (options: { message?: string; yes?: boolean }, command) => { await releaseCreateCommand({ - appKey: options.app, + appKey: resolveReleaseAppKey(command), message: options.message, yes: options.yes, }); @@ -112,12 +119,11 @@ releaseCmd releaseCmd .command('list') .description('List releases for an app.') - .option('--app ', 'App alias to use (defaults to "default")') .option('--limit ', 'Maximum number of releases to show (default: 20)', (v) => Number(v), 20) .option('--json', 'Print releases as JSON (for scripts)', false) - .action(async (options: { app?: string; limit?: number; json?: boolean }) => { + .action(async (options: { limit?: number; json?: boolean }, command) => { await releaseListCommand({ - appKey: options.app, + appKey: resolveReleaseAppKey(command), limit: options.limit, json: options.json, }); @@ -128,10 +134,9 @@ releaseCmd .description( 'Use a release (snapshot) to update local files (run "ensemble push" to sync cloud).' ) - .option('--app ', 'App alias to use (defaults to "default")') .option('--hash ', 'Release hash to use (non-interactive).') - .action(async (options: { app?: string; hash?: string }) => { - await releaseUseCommand({ appKey: options.app, hash: options.hash }); + .action(async (options: { hash?: string }, command) => { + await releaseUseCommand({ appKey: resolveReleaseAppKey(command), hash: options.hash }); }); // If user runs just `ensemble release`, offer an interactive menu. diff --git a/tests/cloud/firestoreClient.test.ts b/tests/cloud/firestoreClient.test.ts index 8bbf9eb..61fbbf3 100644 --- a/tests/cloud/firestoreClient.test.ts +++ b/tests/cloud/firestoreClient.test.ts @@ -852,6 +852,35 @@ describe('createVersion', () => { expect(capturedUrl).toContain('documentId='); }); + it('uses provided id as document id', async () => { + let capturedUrl: string | null = null; + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const urlStr = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (urlStr.includes('/versions') && init?.method === 'POST') { + capturedUrl = urlStr; + return new Response(JSON.stringify({}), { status: 200 }); + } + return new Response('', { status: 404 }); + }; + + const result = await createVersion('app1', 'token', { + id: 'abc123', + message: 'Release 1', + createdAt: '2025-01-15T12:00:00Z', + expiresAt: '2025-02-15T12:00:00Z', + createdBy: { name: 'User', id: 'uid1' }, + snapshotPath: 'releases/app1/abc123.json', + }); + + expect(result.id).toBe('abc123'); + expect(capturedUrl).toContain('documentId=abc123'); + }); + it('throws FirestoreClientError on 403', async () => { globalThis.fetch = async (input: RequestInfo | URL) => { const urlStr = diff --git a/tests/commands/release.test.ts b/tests/commands/release.test.ts index d375671..946e9d8 100644 --- a/tests/commands/release.test.ts +++ b/tests/commands/release.test.ts @@ -91,6 +91,7 @@ import { releaseListCommand, releaseUseCommand, } from '../../src/commands/release.js'; +import { resolveAppContext } from '../../src/config/projectConfig.js'; import type { CloudApp } from '../../src/cloud/firestoreClient.js'; import { EnsembleDocumentType } from '../../src/core/dto.js'; @@ -195,6 +196,27 @@ describe('release commands', () => { } }); + it('release create hints alias-specific use command for non-default app', async () => { + vi.mocked(resolveAppContext).mockResolvedValueOnce({ + projectRoot, + config: { + default: 'dev', + apps: { + dev: { appId: 'app-dev', name: 'Dev App' }, + uat: { appId: 'app-uat', name: 'Uat App' }, + }, + }, + appKey: 'uat', + appId: 'app-uat', + }); + + await releaseCreateCommand({ appKey: 'uat', message: 'uat release', yes: true }); + + expect(uiSuccessMock).toHaveBeenCalledWith( + 'Release saved. Run "ensemble release use --app uat" to use it.' + ); + }); + it('release use restores snapshot config and never touches secrets', async () => { downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( JSON.stringify({ diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 92cf6aa..97dd683 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -19,7 +19,9 @@ import { } from '../../src/core/envSync.js'; import { envConfigScopedFile, envSecretsScopedFile } from '../../src/core/envConfig.js'; -function localEnv(overrides: Partial & Pick): LocalEnvFiles { +function localEnv( + overrides: Partial & Pick +): LocalEnvFiles { const baseConfig = overrides.baseConfig ?? overrides.envConfig; return { appKey: 'default', @@ -479,13 +481,18 @@ describe('envSync', () => { }); it('applyReleaseConfigToFs restores full snapshot config', async () => { - await applyReleaseConfigToFs(tmpDir, { - envVariables: { - assets: 'https://cdn.example.com/', - logo_png: 'logo.png', - E1: 'EV1', + await applyReleaseConfigToFs( + tmpDir, + { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png', + E1: 'EV1', + }, }, - }, 'default', 'default'); + 'default', + 'default' + ); const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); expect(envConfig).toContain('assets=https://cdn.example.com/'); From 22c7a4d825cd057de15a607c13eb047feb4404ac Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Thu, 18 Jun 2026 18:41:11 +0500 Subject: [PATCH 10/10] fix(release): honor --app, sync version ids --- src/cloud/firestoreClient.ts | 11 +-- src/commands/release.ts | 12 ++- src/index.ts | 12 +-- tests/cli/releaseAppOption.test.ts | 33 +++++++ tests/cloud/firestoreClient.test.ts | 33 +------ tests/commands/release.test.ts | 128 +++++++++++++++++++++++----- 6 files changed, 164 insertions(+), 65 deletions(-) create mode 100644 tests/cli/releaseAppOption.test.ts diff --git a/src/cloud/firestoreClient.ts b/src/cloud/firestoreClient.ts index f6fe37e..1c5f047 100644 --- a/src/cloud/firestoreClient.ts +++ b/src/cloud/firestoreClient.ts @@ -216,8 +216,8 @@ export interface VersionMetadata { export type VersionDoc = VersionMetadata; export interface CreateVersionParams { - /** When set, used as the Firestore version document id (and should match the storage object name). */ - id?: string; + /** Firestore version document id; must match the storage object name. */ + id: string; message: string; createdAt: string; createdBy: { name: string; email?: string; id: string }; @@ -1420,11 +1420,6 @@ export async function fetchRootScreenName( return parseFirestoreString(doc.fields.name as { stringValue?: string }); } -/** Generate a URL-safe ID for version documents. */ -function generateVersionId(): string { - return crypto.randomUUID().replace(/-/g, ''); -} - /** * Create a version (snapshot) document under apps/{appId}/versions. * expiresAt must be stored as Firestore Timestamp for TTL policy to work. @@ -1436,7 +1431,7 @@ export async function createVersion( options?: FirestoreClientOptions ): Promise<{ id: string }> { const project = getEnsembleFirebaseProject(); - const versionId = params.id ?? generateVersionId(); + const versionId = params.id; const parent = `projects/${project}/databases/(default)/documents/apps/${appId}`; const url = `https://firestore.googleapis.com/v1/${parent}/versions?documentId=${encodeURIComponent(versionId)}`; diff --git a/src/commands/release.ts b/src/commands/release.ts index ea0a987..c198cca 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -1,3 +1,4 @@ +import { Command } from 'commander'; import crypto from 'crypto'; import prompts from 'prompts'; @@ -67,6 +68,15 @@ function releaseUseHint(appKey: string, defaultAppKey: string): string { return appKey === defaultAppKey ? 'ensemble release use' : `ensemble release use --app ${appKey}`; } +function releasePushHint(appKey: string, defaultAppKey: string): string { + return appKey === defaultAppKey ? 'ensemble push' : `ensemble push --app ${appKey}`; +} + +/** Commander stores --app on the release parent when subcommands also declare it; read parent opts. */ +export function resolveReleaseAppKey(command: Command): string | undefined { + return command.parent?.opts()?.app as string | undefined; +} + export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): Promise { const root = process.cwd(); const { config, appKey, appId } = await resolveAppContext(options.appKey); @@ -399,7 +409,7 @@ export async function releaseUseCommand(options: ReleaseUseOptions = {}): Promis ); await applyReleaseConfigToFs(projectRoot, snapshot.config, appKey, config.default); ui.success( - 'Local files updated to selected release. Run "ensemble push" to apply to the cloud.' + `Local files updated to selected release. Run "${releasePushHint(appKey, config.default)}" to apply to the cloud.` ); } catch (err) { if (err instanceof FirestoreClientError) { diff --git a/src/index.ts b/src/index.ts index abd87c7..394a06f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,18 +14,18 @@ import { initCommand } from './commands/init.js'; import { pushCommand } from './commands/push.js'; import { addCommand } from './commands/add.js'; import { pullCommand } from './commands/pull.js'; -import { releaseCreateCommand, releaseListCommand, releaseUseCommand } from './commands/release.js'; +import { + releaseCreateCommand, + releaseListCommand, + releaseUseCommand, + resolveReleaseAppKey, +} from './commands/release.js'; import { updateCommand } from './commands/update.js'; import { printCliError, resolveDebugFlag } from './core/cliError.js'; import { ui } from './core/ui.js'; const program = new Command(); -/** Commander stores --app on the release parent when subcommands also declare it; read parent opts. */ -function resolveReleaseAppKey(command: Command): string | undefined { - return command.parent?.opts()?.app as string | undefined; -} - program .name('ensemble') .description('Ensemble CLI for logging in and configuring Ensemble apps.') diff --git a/tests/cli/releaseAppOption.test.ts b/tests/cli/releaseAppOption.test.ts new file mode 100644 index 0000000..be0c904 --- /dev/null +++ b/tests/cli/releaseAppOption.test.ts @@ -0,0 +1,33 @@ +import { Command } from 'commander'; +import { describe, expect, it } from 'vitest'; + +import { resolveReleaseAppKey } from '../../src/commands/release.js'; + +function buildReleaseCli(onAppKey: (appKey: string | undefined) => void): Command { + const program = new Command(); + const releaseCmd = program.command('release').option('--app ', 'App alias'); + releaseCmd.command('create').action((_options, command) => { + onAppKey(resolveReleaseAppKey(command)); + }); + releaseCmd.command('list').action((_options, command) => { + onAppKey(resolveReleaseAppKey(command)); + }); + return program; +} + +describe('release --app CLI parsing', () => { + it.each([ + ['release create --app uat', 'uat'], + ['release --app uat create', 'uat'], + ['release list --app uat', 'uat'], + ['release --app uat list', 'uat'], + ['release create', undefined], + ])('parses %s', (argv, expectedAppKey) => { + let resolved: string | undefined; + const program = buildReleaseCli((appKey) => { + resolved = appKey; + }); + program.parse(argv.split(' '), { from: 'user' }); + expect(resolved).toBe(expectedAppKey); + }); +}); diff --git a/tests/cloud/firestoreClient.test.ts b/tests/cloud/firestoreClient.test.ts index 61fbbf3..2a613f3 100644 --- a/tests/cloud/firestoreClient.test.ts +++ b/tests/cloud/firestoreClient.test.ts @@ -822,37 +822,7 @@ describe('createVersion', () => { globalThis.fetch = originalFetch; }); - it('POSTs to app versions collection and returns id', async () => { - let capturedUrl: string | null = null; - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const urlStr = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - if (urlStr.includes('/versions') && init?.method === 'POST') { - capturedUrl = urlStr; - return new Response(JSON.stringify({}), { status: 200 }); - } - return new Response('', { status: 404 }); - }; - - const result = await createVersion('app1', 'token', { - message: 'Release 1', - createdAt: '2025-01-15T12:00:00Z', - expiresAt: '2025-02-15T12:00:00Z', - createdBy: { name: 'User', id: 'uid1' }, - snapshotPath: 'releases/app1/ver-123.json', - }); - - expect(typeof result.id).toBe('string'); - expect(result.id.length).toBeGreaterThan(0); - expect(capturedUrl).toContain('/documents/apps/app1/versions'); - expect(capturedUrl).toContain('documentId='); - }); - - it('uses provided id as document id', async () => { + it('POSTs to app versions collection with provided id', async () => { let capturedUrl: string | null = null; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { const urlStr = @@ -897,6 +867,7 @@ describe('createVersion', () => { await expect( createVersion('app1', 'token', { + id: 'ver-123', message: 'v1', createdAt: '2025-01-15T12:00:00Z', expiresAt: '2025-02-15T12:00:00Z', diff --git a/tests/commands/release.test.ts b/tests/commands/release.test.ts index 946e9d8..172bbd9 100644 --- a/tests/commands/release.test.ts +++ b/tests/commands/release.test.ts @@ -21,25 +21,7 @@ const uiNoteMock = vi.hoisted(() => vi.fn()); let projectRoot: string; vi.mock('../../src/config/projectConfig.js', () => ({ - resolveAppContext: vi.fn(async (requestedAppKey?: string) => { - const appKey = requestedAppKey ?? 'default'; - return { - projectRoot: projectRootRef.value, - config: { - default: 'default', - apps: { - default: { - appId: 'app1', - name: 'App', - appHome: undefined, - options: appOptionsRef.value, - }, - }, - }, - appKey, - appId: 'app1', - }; - }), + resolveAppContext: vi.fn(), })); vi.mock('../../src/auth/session.js', () => ({ @@ -95,6 +77,26 @@ import { resolveAppContext } from '../../src/config/projectConfig.js'; import type { CloudApp } from '../../src/cloud/firestoreClient.js'; import { EnsembleDocumentType } from '../../src/core/dto.js'; +function defaultAppContext(requestedAppKey?: string) { + const appKey = requestedAppKey ?? 'default'; + return { + projectRoot: projectRootRef.value, + config: { + default: 'default', + apps: { + default: { + appId: 'app1', + name: 'App', + appHome: undefined, + options: appOptionsRef.value, + }, + }, + }, + appKey, + appId: 'app1', + }; +} + async function writeEnvConfig(projectRoot: string, lines: string[]): Promise { await fs.writeFile(path.join(projectRoot, '.env.config'), `${lines.join('\n')}\n`, 'utf8'); } @@ -114,6 +116,9 @@ describe('release commands', () => { projectRootRef.value = projectRoot; appOptionsRef.value = {}; process.chdir(projectRoot); + vi.mocked(resolveAppContext).mockImplementation(async (requestedAppKey?: string) => + defaultAppContext(requestedAppKey) + ); // Minimal app files for buildDocumentsFromParsed: appHome is "Home". await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); @@ -217,6 +222,91 @@ describe('release commands', () => { ); }); + it('release create passes the same version id to storage upload and Firestore', async () => { + await releaseCreateCommand({ message: 'sync ids', yes: true }); + + const uploadVersionId = uploadReleaseSnapshotMock.mock.calls[0]?.[2]; + const createParams = createVersionMock.mock.calls[0]?.[2] as { id: string }; + expect(typeof uploadVersionId).toBe('string'); + expect(createParams.id).toBe(uploadVersionId); + }); + + it('release use restores config to scoped alias file for non-default app', async () => { + vi.mocked(resolveAppContext).mockResolvedValueOnce({ + projectRoot, + config: { + default: 'dev', + apps: { + dev: { appId: 'app-dev', name: 'Dev App' }, + uat: { appId: 'app-uat', name: 'Uat App' }, + }, + }, + appKey: 'uat', + appId: 'app-uat', + }); + getVersionMock.mockResolvedValue({ + id: 'hash-1', + message: 'Uat release', + createdAt: '2025-01-15T12:00:00Z', + createdBy: { name: 'User', id: 'uid1' }, + expiresAt: '2025-02-15T12:00:00Z', + snapshotPath: 'releases/app-uat/hash-1.json', + }); + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + JSON.stringify({ + id: 'app-uat', + name: 'Uat App', + screens: [], + config: { envVariables: { E1: 'UAT-EV1' } }, + } satisfies CloudApp) + ); + await fs.writeFile(path.join(projectRoot, '.env.config'), 'E1=dev\n', 'utf8'); + await fs.writeFile(path.join(projectRoot, '.env.config.uat'), 'E1=old-uat\n', 'utf8'); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ appKey: 'uat', hash: 'hash-1' }); + + const baseConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); + const uatConfig = await fs.readFile(path.join(projectRoot, '.env.config.uat'), 'utf8'); + expect(baseConfig).toContain('E1=dev'); + expect(uatConfig).toContain('E1=UAT-EV1'); + expect(uatConfig).not.toContain('old-uat'); + }); + + it('release use hints alias-specific push command for non-default app', async () => { + vi.mocked(resolveAppContext).mockResolvedValueOnce({ + projectRoot, + config: { + default: 'dev', + apps: { + dev: { appId: 'app-dev', name: 'Dev App' }, + uat: { appId: 'app-uat', name: 'Uat App' }, + }, + }, + appKey: 'uat', + appId: 'app-uat', + }); + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + JSON.stringify({ + id: 'app-uat', + name: 'Uat App', + screens: [], + config: { envVariables: { E1: 'UAT-EV1' } }, + } satisfies CloudApp) + ); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ appKey: 'uat', hash: 'hash-1' }); + + expect(uiSuccessMock).toHaveBeenCalledWith( + 'Local files updated to selected release. Run "ensemble push --app uat" to apply to the cloud.' + ); + }); + it('release use restores snapshot config and never touches secrets', async () => { downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( JSON.stringify({