Skip to content

Commit 939bc4e

Browse files
committed
feat: implement asset upload and management in CLI push command
- Enhanced the `submitCliPush` function to support optional asset uploads alongside Firestore YAML operations. - Introduced new interfaces for handling asset upload parameters and results. - Updated the `pushCommand` to include asset upload logic, ensuring assets are uploaded only when necessary. - Added functionality to collect asset filenames and integrate them into the push process. - Implemented tests to verify asset upload behavior and integration with existing commands.
1 parent 8d5dcec commit 939bc4e

13 files changed

Lines changed: 461 additions & 68 deletions

File tree

src/cloud/firestoreClient.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import type {
77
ApplicationDTO,
8+
AssetDTO,
89
WidgetDTO,
910
ScriptDTO,
1011
ActionDTO,
@@ -15,6 +16,7 @@ import type {
1516
import { EnsembleDocumentType } from '../core/dto.js';
1617
import { getArtifactConfig, type ArtifactProp } from '../core/artifacts.js';
1718
import { processWithConcurrency } from '../core/concurrency.js';
19+
import { uploadProjectAssetsForPush } from '../core/pushAssets.js';
1820
import { getEnsembleFirebaseProject } from '../config/env.js';
1921

2022
const DEFAULT_FIRESTORE_CONCURRENCY = 15;
@@ -192,6 +194,7 @@ export type CloudApp = Pick<
192194
| 'screens'
193195
| 'theme'
194196
| 'translations'
197+
| 'assets'
195198
>;
196199

197200
/** Metadata for a saved version (commit); snapshot stored in same doc. */
@@ -430,16 +433,27 @@ async function applyYamlOperationsForKind(
430433
);
431434
}
432435

436+
/** Optional local asset uploads after Firestore YAML apply (studio cloud function + .env.config). */
437+
export interface CliPushExtras {
438+
projectRoot: string;
439+
assetFileNames?: string[];
440+
}
441+
442+
export interface CliPushResult {
443+
assetsUploaded: number;
444+
}
445+
433446
/**
434447
* Apply a push payload directly to Firestore, updating YAML artifacts in-place.
435-
* This updates screens, widgets, scripts, translations, and theme under the app document.
448+
* Optionally uploads new assets (studio-uploadAsset + .env.config) in the same operation.
436449
*/
437450
export async function submitCliPush(
438451
appId: string,
439452
idToken: string,
440453
payload: unknown,
441-
options?: FirestoreClientOptions
442-
): Promise<void> {
454+
options?: FirestoreClientOptions,
455+
extras?: CliPushExtras
456+
): Promise<CliPushResult> {
443457
const project = getEnsembleFirebaseProject();
444458
assertValidPushPayload(payload);
445459
const p = payload as PushPayloadShape;
@@ -459,6 +473,18 @@ export async function submitCliPush(
459473
if (p.theme) {
460474
await applyYamlOperationsForKind('theme', appId, idToken, project, [p.theme], options);
461475
}
476+
477+
const names = extras?.assetFileNames?.filter((n) => n.trim() !== '') ?? [];
478+
if (names.length === 0 || !extras?.projectRoot) {
479+
return { assetsUploaded: 0 };
480+
}
481+
const assetsUploaded = await uploadProjectAssetsForPush(
482+
appId,
483+
idToken,
484+
extras.projectRoot,
485+
names
486+
);
487+
return { assetsUploaded };
462488
}
463489

464490
function parseFirestoreString(field: { stringValue?: string } | undefined): string | undefined {
@@ -731,6 +757,22 @@ function toTranslationDTO(doc: FirestoreDocument, defaultLocale: boolean): Trans
731757
};
732758
}
733759

760+
function toAssetDTO(doc: FirestoreDocument): AssetDTO {
761+
const base = firestoreDocToEnsembleBase(doc);
762+
const fields = (doc.fields ?? {}) as FirestoreFields;
763+
const fileName = parseFirestoreString(fields.fileName as { stringValue?: string }) ?? base.name;
764+
const publicUrl = parseFirestoreString(fields.publicUrl as { stringValue?: string });
765+
const copyText = parseFirestoreString(fields.copyText as { stringValue?: string });
766+
return {
767+
...base,
768+
name: fileName,
769+
fileName,
770+
type: EnsembleDocumentType.Asset,
771+
...(publicUrl !== undefined && { publicUrl }),
772+
...(copyText !== undefined && { copyText }),
773+
};
774+
}
775+
734776
function getCollaboratorRole(
735777
collaboratorsField:
736778
| { mapValue?: { fields?: Record<string, { stringValue?: string }> } }
@@ -984,13 +1026,15 @@ export async function fetchCloudApp(
9841026

9851027
const screens: ScreenDTO[] = [];
9861028
const translations: TranslationDTO[] = [];
1029+
const assets: AssetDTO[] = [];
9871030
let theme: ThemeDTO | undefined;
9881031
const i18nDocs: FirestoreDocument[] = [];
9891032
for (const doc of artifacts) {
9901033
const docId = getDocId(doc.name);
9911034
const type = parseFirestoreString((doc.fields?.type as { stringValue?: string }) ?? undefined);
9921035
if (type === 'screen') screens.push(toScreenDTO(doc));
9931036
else if (type === 'i18n') i18nDocs.push(doc);
1037+
else if (type === 'asset') assets.push(toAssetDTO(doc));
9941038
else if (type === 'theme') {
9951039
if (!theme || docId === 'theme') {
9961040
theme = toThemeDTO(doc);
@@ -1020,6 +1064,7 @@ export async function fetchCloudApp(
10201064
screens,
10211065
...(theme && { theme }),
10221066
...(translations.length > 0 && { translations }),
1067+
...(assets.length > 0 && { assets }),
10231068
};
10241069
}
10251070

src/commands/add.ts

Lines changed: 1 addition & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { loadProjectConfig } from '../config/projectConfig.js';
66
import { resolveAppContext } from '../config/projectConfig.js';
77
import { getValidAuthSession } from '../auth/session.js';
88
import { uploadAssetToStudio } from '../cloud/assetClient.js';
9+
import { upsertEnvConfig } from '../core/envConfig.js';
910
import { upsertManifestEntry, type RootManifest } from '../core/manifest.js';
1011
import { ui } from '../core/ui.js';
1112
import { withSpinner } from '../lib/spinner.js';
@@ -56,53 +57,6 @@ async function fileExists(filePath: string): Promise<boolean> {
5657
}
5758
}
5859

59-
function parseEnvConfig(raw: string): {
60-
lines: string[];
61-
keyToLineIndex: Map<string, number>;
62-
} {
63-
const lines = raw.split(/\r?\n/);
64-
const keyToLineIndex = new Map<string, number>();
65-
for (let i = 0; i < lines.length; i += 1) {
66-
const line = lines[i].trim();
67-
if (!line || line.startsWith('#')) continue;
68-
const eq = line.indexOf('=');
69-
if (eq <= 0) continue;
70-
const key = line.slice(0, eq).trim();
71-
if (key) keyToLineIndex.set(key, i);
72-
}
73-
return { lines, keyToLineIndex };
74-
}
75-
76-
async function upsertEnvConfig(
77-
projectRoot: string,
78-
entries: Array<{ key: string; value: string; overwrite?: boolean }>
79-
): Promise<void> {
80-
const envPath = path.join(projectRoot, '.env.config');
81-
let raw = '';
82-
try {
83-
raw = await fs.readFile(envPath, 'utf8');
84-
} catch {
85-
raw = '';
86-
}
87-
const parsed = parseEnvConfig(raw);
88-
// Avoid introducing visual gaps when appending new entries.
89-
while (parsed.lines.length > 0 && parsed.lines[parsed.lines.length - 1].trim() === '') {
90-
parsed.lines.pop();
91-
}
92-
for (const entry of entries) {
93-
const line = `${entry.key}=${entry.value}`;
94-
const existingIdx = parsed.keyToLineIndex.get(entry.key);
95-
if (existingIdx === undefined) {
96-
parsed.lines.push(line);
97-
parsed.keyToLineIndex.set(entry.key, parsed.lines.length - 1);
98-
} else if (entry.overwrite !== false) {
99-
parsed.lines[existingIdx] = line;
100-
}
101-
}
102-
const normalized = parsed.lines.join('\n').replace(/\n*$/, '\n');
103-
await fs.writeFile(envPath, normalized, 'utf8');
104-
}
105-
10660
async function addAsset(
10761
projectRoot: string,
10862
assetPathInput: string

src/commands/push.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,31 @@ export interface PushOptions {
3434

3535
const DESTRUCTIVE_CHANGE_PROMPT_THRESHOLD = 25;
3636

37+
/** Firestore/YAML artifact changes only (not asset uploads via studio function). */
38+
function yamlArtifactChangeTotal(summary: PushSummary): number {
39+
const k = summary.byKind;
40+
return (
41+
k.screens.created +
42+
k.screens.updated +
43+
k.screens.deleted +
44+
k.widgets.created +
45+
k.widgets.updated +
46+
k.widgets.deleted +
47+
k.scripts.created +
48+
k.scripts.updated +
49+
k.scripts.deleted +
50+
k.actions.created +
51+
k.actions.updated +
52+
k.actions.deleted +
53+
k.translations.created +
54+
k.translations.updated +
55+
k.translations.deleted +
56+
k.theme.created +
57+
k.theme.updated +
58+
k.theme.deleted
59+
);
60+
}
61+
3762
function printPushSummary(summary: PushSummary, options: { verbose?: boolean; isNoop?: boolean }) {
3863
const { appName, environment, counts } = summary;
3964
const totalChanges = counts.created + counts.updated + counts.deleted;
@@ -60,6 +85,7 @@ function printPushSummary(summary: PushSummary, options: { verbose?: boolean; is
6085
['actions', summary.byKind.actions],
6186
['translations', summary.byKind.translations],
6287
['theme', summary.byKind.theme],
88+
['assets', summary.byKind.assets],
6389
];
6490

6591
for (const [kind, c] of entries) {
@@ -307,9 +333,12 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
307333
});
308334

309335
const summary = plan.summary;
310-
const changedCount = summary.counts.created + summary.counts.updated + summary.counts.deleted;
336+
const yamlChangeTotal = yamlArtifactChangeTotal(summary);
337+
const assetsToUpload = plan.diff.assets.new
338+
.map((item) => (item as { fileName?: string }).fileName)
339+
.filter((fn): fn is string => typeof fn === 'string' && fn.length > 0);
311340

312-
if (changedCount === 0) {
341+
if (yamlChangeTotal === 0 && assetsToUpload.length === 0) {
313342
ui.info('Up to date. Nothing to push.');
314343
return;
315344
}
@@ -352,9 +381,7 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
352381
let confirmed = options.yes ?? false;
353382
const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
354383
const hasDeletes = summary.counts.deleted > 0;
355-
const largeChangeSet =
356-
summary.counts.created + summary.counts.updated + summary.counts.deleted >=
357-
DESTRUCTIVE_CHANGE_PROMPT_THRESHOLD;
384+
const largeChangeSet = yamlChangeTotal >= DESTRUCTIVE_CHANGE_PROMPT_THRESHOLD;
358385

359386
if (!confirmed) {
360387
if (!isInteractive) {
@@ -365,12 +392,28 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
365392
return;
366393
}
367394

395+
const yamlCreatedOrUpdated =
396+
summary.byKind.screens.created +
397+
summary.byKind.screens.updated +
398+
summary.byKind.widgets.created +
399+
summary.byKind.widgets.updated +
400+
summary.byKind.scripts.created +
401+
summary.byKind.scripts.updated +
402+
summary.byKind.actions.created +
403+
summary.byKind.actions.updated +
404+
summary.byKind.translations.created +
405+
summary.byKind.translations.updated +
406+
summary.byKind.theme.created +
407+
summary.byKind.theme.updated;
408+
368409
const headline =
369410
hasDeletes || largeChangeSet
370-
? `This will delete ${summary.counts.deleted} item(s) and apply ${
371-
summary.counts.created + summary.counts.updated
372-
} other change(s). Continue? [y/N]`
373-
: 'Proceed with push?';
411+
? `This will delete ${summary.counts.deleted} item(s) and apply ${yamlCreatedOrUpdated} other change(s)${
412+
assetsToUpload.length > 0 ? `, and upload ${assetsToUpload.length} asset(s)` : ''
413+
}. Continue? [y/N]`
414+
: `Proceed with push${
415+
assetsToUpload.length > 0 ? ` and upload ${assetsToUpload.length} asset(s)` : ''
416+
}?`;
374417

375418
const { proceed } = await prompts({
376419
type: 'confirm',
@@ -391,9 +434,17 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
391434
}
392435

393436
try {
394-
await withSpinner('Pushing changes to cloud...', () =>
395-
submitCliPush(appId, idToken, pushPayload, firestoreOptions)
396-
);
437+
if (yamlChangeTotal > 0 || assetsToUpload.length > 0) {
438+
const { assetsUploaded } = await withSpinner('Pushing changes to cloud...', () =>
439+
submitCliPush(appId, idToken, pushPayload, firestoreOptions, {
440+
projectRoot: root,
441+
...(assetsToUpload.length > 0 && { assetFileNames: assetsToUpload }),
442+
})
443+
);
444+
if (assetsUploaded > 0) {
445+
ui.success(`Uploaded ${assetsUploaded} asset(s) and updated .env.config.`);
446+
}
447+
}
397448

398449
if (manifestNeedsRefresh && bundle) {
399450
// Only refresh manifest when artifact changes can affect its contents.

src/commands/release.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}):
118118
...(localApp.translations &&
119119
localApp.translations.length > 0 && { translations: localApp.translations }),
120120
...(localApp.theme && { theme: localApp.theme }),
121+
...(localApp.assets && localApp.assets.length > 0 && { assets: localApp.assets }),
121122
};
122123

123124
try {

src/core/appCollector.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface ParsedAppFiles {
1717
actions: Record<string, string>;
1818
translations: Record<string, string>;
1919
theme?: string;
20+
/** Basenames of files under assets/ (binary files are not read into memory). */
21+
assetFiles?: string[];
2022
}
2123

2224
export type CollectOptions = Partial<Record<ArtifactProp, boolean>>;
@@ -36,6 +38,19 @@ async function readTextFile(filePath: string): Promise<string> {
3638
return fs.readFile(filePath, 'utf8');
3739
}
3840

41+
async function collectAssetBasenames(rootDir: string): Promise<string[]> {
42+
const assetsDir = path.join(rootDir, 'assets');
43+
try {
44+
const entries = await fs.readdir(assetsDir, { withFileTypes: true });
45+
return entries
46+
.filter((e) => e.isFile())
47+
.map((e) => e.name)
48+
.sort();
49+
} catch {
50+
return [];
51+
}
52+
}
53+
3954
export async function collectAppFiles(
4055
rootDir: string,
4156
collectOptions: CollectOptions = {},
@@ -138,6 +153,11 @@ export async function collectAppFiles(
138153

139154
await walk(rootDir);
140155

156+
const assetFiles = await collectAssetBasenames(rootDir);
157+
if (assetFiles.length > 0) {
158+
result.assetFiles = assetFiles;
159+
}
160+
141161
reportStatus('reading', {
142162
rootDir,
143163
taskCount: tasks.length,
@@ -175,6 +195,7 @@ export async function collectAppFiles(
175195
scriptCount: Object.keys(result.scripts).length,
176196
widgetCount: Object.keys(result.widgets).length,
177197
translationCount: Object.keys(result.translations).length,
198+
assetCount: assetFiles.length,
178199
hasTheme: typeof result.theme === 'string',
179200
});
180201

0 commit comments

Comments
 (0)