Skip to content

Commit 470b0c0

Browse files
committed
sync local env files with cloud on pull/push
1 parent af183e2 commit 470b0c0

12 files changed

Lines changed: 1472 additions & 20 deletions

File tree

src/cloud/firestoreClient.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import type {
1212
ScreenDTO,
1313
ThemeDTO,
1414
TranslationDTO,
15+
ConfigDTO,
16+
SecretDTO,
1517
} from '../core/dto.js';
1618
import { EnsembleDocumentType } from '../core/dto.js';
1719
import { getArtifactConfig, type ArtifactProp } from '../core/artifacts.js';
@@ -195,6 +197,8 @@ export type CloudApp = Pick<
195197
| 'theme'
196198
| 'translations'
197199
| 'assets'
200+
| 'config'
201+
| 'secrets'
198202
>;
199203

200204
/** Metadata for a saved version (commit); snapshot stored in same doc. */
@@ -773,6 +777,218 @@ function toAssetDTO(doc: FirestoreDocument): AssetDTO {
773777
};
774778
}
775779

780+
function parseFirestoreMapField(
781+
field: { mapValue?: { fields?: Record<string, FirestoreValue> } } | undefined
782+
): Record<string, unknown> | undefined {
783+
const mapFields = field?.mapValue?.fields;
784+
if (!mapFields || typeof mapFields !== 'object') return undefined;
785+
const result: Record<string, unknown> = {};
786+
for (const [key, value] of Object.entries(mapFields)) {
787+
if (typeof (value as { stringValue?: string }).stringValue === 'string') {
788+
result[key] = (value as { stringValue: string }).stringValue;
789+
continue;
790+
}
791+
if (typeof (value as { booleanValue?: boolean }).booleanValue === 'boolean') {
792+
result[key] = (value as { booleanValue: boolean }).booleanValue;
793+
continue;
794+
}
795+
if ((value as { integerValue?: string }).integerValue !== undefined) {
796+
result[key] = Number((value as { integerValue: string }).integerValue);
797+
}
798+
}
799+
return Object.keys(result).length > 0 ? result : undefined;
800+
}
801+
802+
function encodeFirestoreStringMap(values: Record<string, string>): {
803+
mapValue: { fields: Record<string, { stringValue: string }> };
804+
} {
805+
const fields: Record<string, { stringValue: string }> = {};
806+
for (const [key, value] of Object.entries(values)) {
807+
fields[key] = { stringValue: value };
808+
}
809+
return { mapValue: { fields } };
810+
}
811+
812+
function configDtoToStringMap(config: ConfigDTO): Record<string, string> {
813+
const envVariables = config.envVariables ?? {};
814+
const result: Record<string, string> = {};
815+
for (const [key, value] of Object.entries(envVariables)) {
816+
if (value !== undefined && value !== null) {
817+
result[key] = String(value);
818+
}
819+
}
820+
return result;
821+
}
822+
823+
function secretsDtoToStringMap(secrets: SecretDTO): Record<string, string> {
824+
const nested =
825+
secrets.secrets && typeof secrets.secrets === 'object'
826+
? (secrets.secrets as Record<string, unknown>)
827+
: (secrets as Record<string, unknown>);
828+
const result: Record<string, string> = {};
829+
for (const [key, value] of Object.entries(nested)) {
830+
if (key === 'secrets' || value === undefined || value === null) continue;
831+
result[key] = String(value);
832+
}
833+
return result;
834+
}
835+
836+
function parseJsonObjectField<T extends object>(content: string | undefined): T | undefined {
837+
if (!content) return undefined;
838+
try {
839+
const parsed = JSON.parse(content) as T;
840+
return parsed && typeof parsed === 'object' ? parsed : undefined;
841+
} catch {
842+
return undefined;
843+
}
844+
}
845+
846+
function toConfigDTO(doc: FirestoreDocument): ConfigDTO | undefined {
847+
const fields = (doc.fields ?? {}) as FirestoreFields;
848+
const fromContent = parseJsonObjectField<ConfigDTO>(
849+
parseFirestoreString(fields.content as { stringValue?: string })
850+
);
851+
const envVariablesFromMap = parseFirestoreMapField(
852+
fields.envVariables as { mapValue?: { fields?: Record<string, FirestoreValue> } }
853+
);
854+
const baseUrl = parseFirestoreString(fields.baseUrl as { stringValue?: string });
855+
const useBrowserUrl = parseFirestoreBoolean(fields.useBrowserUrl as { booleanValue?: boolean });
856+
const envVariables =
857+
envVariablesFromMap ?? (fromContent?.envVariables as Record<string, unknown> | undefined);
858+
if (!envVariables && baseUrl === undefined && useBrowserUrl === undefined) {
859+
return undefined;
860+
}
861+
return {
862+
...(envVariables && { envVariables }),
863+
...(baseUrl !== undefined && { baseUrl }),
864+
...(useBrowserUrl !== undefined && { useBrowserUrl }),
865+
};
866+
}
867+
868+
function toSecretDTO(doc: FirestoreDocument): SecretDTO | undefined {
869+
const fields = (doc.fields ?? {}) as FirestoreFields;
870+
const fromContent = parseJsonObjectField<SecretDTO>(
871+
parseFirestoreString(fields.content as { stringValue?: string })
872+
);
873+
const secretsFromMap = parseFirestoreMapField(
874+
fields.secrets as { mapValue?: { fields?: Record<string, FirestoreValue> } }
875+
);
876+
if (secretsFromMap) {
877+
return { secrets: secretsFromMap as Record<string, string> };
878+
}
879+
if (fromContent) return fromContent;
880+
881+
const flat = parseFirestoreMapField(
882+
fields as { mapValue?: { fields?: Record<string, FirestoreValue> } }
883+
);
884+
return flat ? (flat as SecretDTO) : undefined;
885+
}
886+
887+
async function upsertEnvArtifactDocument(
888+
appId: string,
889+
idToken: string,
890+
project: string,
891+
documentId: string,
892+
typeValue: string,
893+
contentJson: string,
894+
mapFieldName: 'envVariables' | 'secrets',
895+
mapValues: Record<string, string>,
896+
options?: FirestoreClientOptions
897+
): Promise<void> {
898+
const collectionUrl = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}/artifacts`;
899+
const docUrl = `${collectionUrl}/${encodeURIComponent(documentId)}`;
900+
const updatedAt = new Date().toISOString();
901+
const patchFields: FirestoreWriteFields = {
902+
content: { stringValue: contentJson },
903+
[mapFieldName]: encodeFirestoreStringMap(mapValues),
904+
updatedAt: { timestampValue: updatedAt },
905+
};
906+
const fieldPaths = ['content', mapFieldName, 'updatedAt'];
907+
908+
logDebug(options, {
909+
kind: 'request',
910+
method: 'PATCH',
911+
url: `${docUrl}?${fieldPaths.map((p) => `updateMask.fieldPaths=${encodeURIComponent(p)}`).join('&')}`,
912+
context: 'submitEnvDocumentsPush/patch',
913+
});
914+
const patchRes = await fetch(
915+
`${docUrl}?${fieldPaths.map((p) => `updateMask.fieldPaths=${encodeURIComponent(p)}`).join('&')}`,
916+
{
917+
method: 'PATCH',
918+
headers: {
919+
Authorization: `Bearer ${idToken}`,
920+
'Content-Type': 'application/json',
921+
},
922+
body: JSON.stringify({ fields: patchFields }),
923+
}
924+
);
925+
926+
if (patchRes.ok) return;
927+
928+
if (patchRes.status !== 404) {
929+
throw await toFirestoreError(`update ${documentId}`, patchRes, options);
930+
}
931+
932+
const createUrl = `${collectionUrl}?documentId=${encodeURIComponent(documentId)}`;
933+
const createFields: FirestoreWriteFields = {
934+
name: { stringValue: documentId },
935+
type: { stringValue: typeValue },
936+
...patchFields,
937+
};
938+
logDebug(options, {
939+
kind: 'request',
940+
method: 'POST',
941+
url: createUrl,
942+
context: 'submitEnvDocumentsPush/create',
943+
});
944+
const createRes = await fetch(createUrl, {
945+
method: 'POST',
946+
headers: {
947+
Authorization: `Bearer ${idToken}`,
948+
'Content-Type': 'application/json',
949+
},
950+
body: JSON.stringify({ fields: createFields }),
951+
});
952+
if (!createRes.ok) {
953+
throw await toFirestoreError(`create ${documentId}`, createRes, options);
954+
}
955+
}
956+
957+
export async function submitEnvDocumentsPush(
958+
appId: string,
959+
idToken: string,
960+
payload: { config?: ConfigDTO; secrets?: SecretDTO },
961+
options?: FirestoreClientOptions
962+
): Promise<void> {
963+
const project = getEnsembleFirebaseProject();
964+
if (payload.config) {
965+
await upsertEnvArtifactDocument(
966+
appId,
967+
idToken,
968+
project,
969+
'appConfig',
970+
EnsembleDocumentType.Environment,
971+
JSON.stringify(payload.config),
972+
'envVariables',
973+
configDtoToStringMap(payload.config),
974+
options
975+
);
976+
}
977+
if (payload.secrets) {
978+
await upsertEnvArtifactDocument(
979+
appId,
980+
idToken,
981+
project,
982+
'secrets',
983+
EnsembleDocumentType.Secrets,
984+
JSON.stringify(payload.secrets),
985+
'secrets',
986+
secretsDtoToStringMap(payload.secrets),
987+
options
988+
);
989+
}
990+
}
991+
776992
function getCollaboratorRole(
777993
collaboratorsField:
778994
| { mapValue?: { fields?: Record<string, { stringValue?: string }> } }
@@ -1028,13 +1244,17 @@ export async function fetchCloudApp(
10281244
const translations: TranslationDTO[] = [];
10291245
const assets: AssetDTO[] = [];
10301246
let theme: ThemeDTO | undefined;
1247+
let config: ConfigDTO | undefined;
1248+
let secrets: SecretDTO | undefined;
10311249
const i18nDocs: FirestoreDocument[] = [];
10321250
for (const doc of artifacts) {
10331251
const docId = getDocId(doc.name);
10341252
const type = parseFirestoreString((doc.fields?.type as { stringValue?: string }) ?? undefined);
10351253
if (type === 'screen') screens.push(toScreenDTO(doc));
10361254
else if (type === 'i18n') i18nDocs.push(doc);
10371255
else if (type === 'asset') assets.push(toAssetDTO(doc));
1256+
else if (type === 'config' || docId === 'appConfig') config = toConfigDTO(doc) ?? config;
1257+
else if (type === 'secrets' || docId === 'secrets') secrets = toSecretDTO(doc) ?? secrets;
10381258
else if (type === 'theme') {
10391259
if (!theme || docId === 'theme') {
10401260
theme = toThemeDTO(doc);
@@ -1065,6 +1285,8 @@ export async function fetchCloudApp(
10651285
...(theme && { theme }),
10661286
...(translations.length > 0 && { translations }),
10671287
...(assets.length > 0 && { assets }),
1288+
...(config && { config }),
1289+
...(secrets && { secrets }),
10681290
};
10691291
}
10701292

src/commands/pull.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { type RootManifest } from '../core/manifest.js';
2121
import { writeVerboseJson } from '../core/debugFiles.js';
2222
import { computePullPlan, type PullSummary } from '../core/sync.js';
2323
import { applyCloudAssetsToFs, buildEnvConfigForCloudAssets } from '../core/pullAssets.js';
24+
import { applyCloudEnvToFs, readProjectEnvFiles } from '../core/envSync.js';
2425
import { ui } from '../core/ui.js';
2526
import { upsertEnvConfig } from '../core/envConfig.js';
2627

@@ -267,6 +268,7 @@ export async function pullCommand(options: PullOptions = {}): Promise<void> {
267268
localFiles,
268269
manifestExisting,
269270
enabledByProp,
271+
localEnv: await readProjectEnvFiles(projectRoot, localFiles.assetFiles ?? []),
270272
});
271273

272274
if (plan.allArtifactsMatch && plan.manifestMatch) {
@@ -372,6 +374,19 @@ export async function pullCommand(options: PullOptions = {}): Promise<void> {
372374
`Some assets had invalid metadata and may be missing from .env.config (${envResult.failures.length}).`
373375
);
374376
}
377+
378+
await applyCloudEnvToFs(
379+
projectRoot,
380+
{
381+
config: cloudApp.config,
382+
secrets: cloudApp.secrets,
383+
},
384+
(cloudApp.assets ?? [])
385+
.map((asset) => asset.fileName)
386+
.filter(
387+
(fileName): fileName is string => typeof fileName === 'string' && fileName.length > 0
388+
)
389+
);
375390
});
376391

377392
printPullSummary(pullSummary);

0 commit comments

Comments
 (0)