Skip to content

Commit 1973d16

Browse files
committed
feat: enhance release management with storage integration and verbose error handling
- Introduced `StorageClient` for uploading and downloading release snapshots to/from Firebase Storage. - Updated `release create` command to upload snapshots and store their paths in Firestore. - Enhanced error handling in release commands to provide verbose output for debugging. - Modified README to reflect new command options and clarify the release creation process. - Added tests for storage client functionality and updated existing tests to accommodate changes in snapshot handling.
1 parent 4c956f8 commit 1973d16

File tree

9 files changed

+317
-75
lines changed

9 files changed

+317
-75
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,12 @@ To release a new version, go to GitHub → Actions → run the workflow **Releas
9898
- **release create**`--app <alias>` — App alias (default: `default`)
9999
- **release create**`-m, --message <msg>` — Release message (skips prompt)
100100
- **release create**`-y, --yes` — Skip message prompt (use empty message)
101+
- **release create**`--verbose` — Show full Firestore/Storage error response text (debugging)
101102
- **release list**`--app <alias>` — App alias (default: `default`)
102103
- **release list**`--limit <n>` — Maximum number of releases to show (default: 20)
103104
- **release list**`--json` — Print releases as machine-readable JSON (for scripts)
104105
- **release use**`--app <alias>` — App alias (default: `default`)
106+
- **release use**`--hash <hash>` — Non-interactive: use release by hash
105107
- **release use**`--hash <hash>` — Non-interactive: use release by hash (printed by `release list`)
106108

107109
### `ensemble add`
@@ -158,14 +160,12 @@ To release a new version, go to GitHub → Actions → run the workflow **Releas
158160

159161
You can save and use snapshots of your app state in the cloud:
160162

161-
- **Create a release from cloud:** After you have pushed and verified that the app is working as expected, run **`ensemble release create`** to save a snapshot (release) of the **current cloud state** with an optional message.
163+
- **Create a release from local state:** After you have local changes you want to “tag”, run **`ensemble release create`** to save a snapshot (release) of the **current local app state** with an optional message.
162164
- **List releases:** Run **`ensemble release list`** to see recent releases.
163165
- **Use a release locally:** Run **`ensemble release use`** to choose a release and update **local files only** to that snapshot. Then run **`ensemble push`** to apply that state to the cloud.
164166

165167
When you run `ensemble release` **without a subcommand** in an interactive terminal, the CLI opens an interactive menu that lets you choose between **create**, **list**, and **use**. In non-interactive environments (e.g. CI), you must call an explicit subcommand such as `ensemble release list` or `ensemble release use --hash <hash>`.
166168

167-
Releases are retained for **30 days**; after that they are deleted automatically by Firestore TTL.
168-
169169
### Exit codes
170170

171171
- `0` — Command completed successfully (including “Up to date. Nothing to push/pull.”).

src/cloud/firestoreClient.ts

Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ type FirestoreValue =
173173
| { stringValue: string }
174174
| { booleanValue: boolean }
175175
| { timestampValue: string }
176+
| { integerValue: string }
176177
| { mapValue: { fields: Record<string, FirestoreValue> } }
177178
| { referenceValue: string };
178179

@@ -200,20 +201,19 @@ export interface VersionMetadata {
200201
createdAt: string;
201202
createdBy: { name: string; email?: string; id: string };
202203
expiresAt: string;
204+
snapshotPath: string;
203205
}
204206

205-
/** Full version doc as returned by getVersion (metadata + snapshot). */
206-
export interface VersionDoc extends VersionMetadata {
207-
snapshot: CloudApp;
208-
}
207+
/** Version doc metadata (snapshot stored in Storage). */
208+
export type VersionDoc = VersionMetadata;
209209

210210
export interface CreateVersionParams {
211211
message: string;
212212
createdAt: string;
213213
createdBy: { name: string; email?: string; id: string };
214214
/** Must be Firestore Timestamp (e.g. 30 days from now). Use ISO string for timestampValue. */
215215
expiresAt: string;
216-
snapshot: CloudApp;
216+
snapshotPath: string;
217217
}
218218

219219
const ALLOWED_ROLES = new Set(['write', 'owner']);
@@ -1114,7 +1114,7 @@ export async function createVersion(
11141114
createdAt: { timestampValue: params.createdAt },
11151115
expiresAt: { timestampValue: params.expiresAt },
11161116
...(createdByVal && { createdBy: createdByVal }),
1117-
snapshot: { stringValue: JSON.stringify(params.snapshot) },
1117+
snapshotPath: { stringValue: params.snapshotPath },
11181118
};
11191119

11201120
logDebug(options, {
@@ -1157,15 +1157,18 @@ function parseVersionDoc(doc: FirestoreDocument): VersionDoc | null {
11571157
name: 'Unknown',
11581158
id: '',
11591159
};
1160-
const snapshotStr = parseFirestoreString(fields.snapshot as { stringValue?: string });
1161-
let snapshot: CloudApp;
1162-
try {
1163-
snapshot = JSON.parse(snapshotStr ?? '{}') as CloudApp;
1164-
} catch {
1165-
return null;
1166-
}
11671160
if (createdAt === undefined || expiresAt === undefined) return null;
1168-
return { id, message, createdAt, createdBy, expiresAt, snapshot };
1161+
const snapshotPath = parseFirestoreString(fields.snapshotPath as { stringValue?: string });
1162+
if (!snapshotPath) return null;
1163+
1164+
return {
1165+
id,
1166+
message,
1167+
createdAt,
1168+
createdBy,
1169+
expiresAt,
1170+
snapshotPath,
1171+
};
11691172
}
11701173

11711174
export interface ListVersionsResult {
@@ -1276,33 +1279,13 @@ export async function getVersion(
12761279
throw await toFirestoreError('get version', res, options);
12771280
}
12781281

1279-
const doc = (await res.json()) as { name?: string; fields?: Record<string, FirestoreValue> };
1280-
const fields = doc?.fields ?? {};
1281-
const id = getDocId(doc.name ?? '');
1282-
const message = parseFirestoreString(fields.message as { stringValue?: string }) ?? '';
1283-
const createdAt = parseFirestoreTimestamp(fields.createdAt as { timestampValue?: string }) ?? '';
1284-
const expiresAt = parseFirestoreTimestamp(fields.expiresAt as { timestampValue?: string }) ?? '';
1285-
const createdBy = parseUpdatedBy(fields.createdBy as { referenceValue?: string }) ?? {
1286-
name: 'Unknown',
1287-
id: '',
1288-
};
1289-
const snapshotStr = parseFirestoreString(fields.snapshot as { stringValue?: string });
1290-
let snapshot: CloudApp;
1291-
try {
1292-
snapshot = JSON.parse(snapshotStr ?? '{}') as CloudApp;
1293-
} catch {
1282+
const doc = (await res.json()) as FirestoreDocument;
1283+
const parsed = parseVersionDoc(doc);
1284+
if (!parsed) {
12941285
throw new FirestoreClientError({
12951286
code: 'UNKNOWN',
1296-
message: 'Version snapshot data is invalid.',
1287+
message: 'Version metadata is invalid.',
12971288
});
12981289
}
1299-
1300-
return {
1301-
id,
1302-
message,
1303-
createdAt,
1304-
createdBy,
1305-
expiresAt,
1306-
snapshot,
1307-
};
1290+
return parsed;
13081291
}

src/cloud/storageClient.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { getEnsembleFirebaseProject } from '../config/env.js';
2+
3+
export class StorageClientError extends Error {
4+
status?: number;
5+
hint?: string;
6+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7+
cause?: any;
8+
9+
constructor(params: { message: string; status?: number; hint?: string; cause?: unknown }) {
10+
super(params.message);
11+
this.name = 'StorageClientError';
12+
this.status = params.status;
13+
this.hint = params.hint;
14+
this.cause = params.cause;
15+
}
16+
}
17+
18+
export interface UploadReleaseSnapshotResult {
19+
bucket: string;
20+
objectPath: string;
21+
}
22+
23+
function objectPathForRelease(appId: string, versionId: string): string {
24+
return `releases/${appId}/${versionId}.json`;
25+
}
26+
27+
function storageAuthHeader(idToken: string): string {
28+
// Firebase Storage v0 API accepts Firebase Auth tokens.
29+
// Note: This is different from Google Cloud Storage OAuth tokens.
30+
return `Firebase ${idToken}`;
31+
}
32+
33+
async function toStorageError(context: string, res: Response): Promise<StorageClientError> {
34+
const text = await res.text();
35+
return new StorageClientError({
36+
message: `Storage ${context} failed (${res.status})`,
37+
status: res.status,
38+
hint:
39+
res.status === 401 || res.status === 403
40+
? 'Authentication/authorization failed for Storage. Check your login session and Storage rules/IAM.'
41+
: undefined,
42+
cause: text.slice(0, 500),
43+
});
44+
}
45+
46+
export async function uploadReleaseSnapshot(
47+
appId: string,
48+
idToken: string,
49+
versionId: string,
50+
snapshotJson: string
51+
): Promise<UploadReleaseSnapshotResult> {
52+
const bucket = `${getEnsembleFirebaseProject()}.appspot.com`;
53+
const objectPath = objectPathForRelease(appId, versionId);
54+
const url = `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(
55+
bucket
56+
)}/o?uploadType=media&name=${encodeURIComponent(objectPath)}`;
57+
58+
const res = await fetch(url, {
59+
method: 'POST',
60+
headers: {
61+
Authorization: storageAuthHeader(idToken),
62+
'Content-Type': 'application/json',
63+
},
64+
body: snapshotJson,
65+
});
66+
67+
if (!res.ok) {
68+
throw await toStorageError('upload release snapshot', res);
69+
}
70+
71+
return { bucket, objectPath };
72+
}
73+
74+
export async function downloadReleaseSnapshotJson(
75+
idToken: string,
76+
snapshotPath: string
77+
): Promise<string> {
78+
const bucket = `${getEnsembleFirebaseProject()}.appspot.com`;
79+
const url = `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent(
80+
bucket
81+
)}/o/${encodeURIComponent(snapshotPath)}?alt=media`;
82+
83+
const res = await fetch(url, {
84+
headers: {
85+
Authorization: storageAuthHeader(idToken),
86+
},
87+
});
88+
89+
if (!res.ok) {
90+
throw await toStorageError('download release snapshot', res);
91+
}
92+
93+
return await res.text();
94+
}

src/commands/release.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import crypto from 'crypto';
12
import prompts from 'prompts';
23

34
import {
@@ -10,6 +11,11 @@ import {
1011
type FirestoreClientOptions,
1112
type VersionDoc,
1213
} from '../cloud/firestoreClient.js';
14+
import {
15+
downloadReleaseSnapshotJson,
16+
StorageClientError,
17+
uploadReleaseSnapshot,
18+
} from '../cloud/storageClient.js';
1319
import { applyCloudStateToFs } from '../core/applyToFs.js';
1420
import { buildDocumentsFromParsed } from '../core/buildDocuments.js';
1521
import { ArtifactProps, type ArtifactProp } from '../core/artifacts.js';
@@ -26,6 +32,8 @@ export interface ReleaseCreateOptions {
2632
message?: string;
2733
/** Skip message prompt (use empty message) */
2834
yes?: boolean;
35+
/** Show verbose error details */
36+
verbose?: boolean;
2937
}
3038

3139
export interface ReleaseListOptions {
@@ -113,6 +121,12 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}):
113121
};
114122

115123
try {
124+
const snapshotJson = JSON.stringify(snapshot);
125+
const versionId = crypto.randomUUID().replace(/-/g, '');
126+
const upload = await withSpinner('Uploading snapshot to storage...', () =>
127+
uploadReleaseSnapshot(appId, idToken, versionId, snapshotJson)
128+
);
129+
116130
await createVersion(
117131
appId,
118132
idToken,
@@ -121,7 +135,7 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}):
121135
createdAt: now.toISOString(),
122136
createdBy: { name: session.name ?? 'User', id: userId },
123137
expiresAt,
124-
snapshot,
138+
snapshotPath: upload.objectPath,
125139
},
126140
firestoreOptions
127141
);
@@ -130,6 +144,19 @@ export async function releaseCreateCommand(options: ReleaseCreateOptions = {}):
130144
if (err instanceof FirestoreClientError) {
131145
ui.error(err.message);
132146
if (err.hint) ui.note(err.hint);
147+
if (options.verbose && err.cause) {
148+
ui.note('Firestore response:');
149+
// eslint-disable-next-line no-console
150+
console.log(typeof err.cause === 'string' ? err.cause : String(err.cause));
151+
}
152+
} else if (err instanceof StorageClientError) {
153+
ui.error(err.message);
154+
if (err.hint) ui.note(err.hint);
155+
if (options.verbose && err.cause) {
156+
ui.note('Storage response:');
157+
// eslint-disable-next-line no-console
158+
console.log(typeof err.cause === 'string' ? err.cause : String(err.cause));
159+
}
133160
} else {
134161
ui.error(err instanceof Error ? err.message : String(err));
135162
}
@@ -339,10 +366,15 @@ export async function releaseUseCommand(options: ReleaseUseOptions = {}): Promis
339366
}
340367
}
341368

369+
const snapshotJson = await withSpinner('Downloading snapshot...', () =>
370+
downloadReleaseSnapshotJson(idToken, versionDoc.snapshotPath)
371+
);
372+
const snapshot = JSON.parse(snapshotJson) as CloudApp;
373+
342374
const localFiles = await collectAppFiles(projectRoot);
343375
const appHome = appConfig.appHome as string | undefined;
344376
await withSpinner('Writing local files...', () =>
345-
applyCloudStateToFs(projectRoot, versionDoc.snapshot, localFiles, enabledByProp, {
377+
applyCloudStateToFs(projectRoot, snapshot, localFiles, enabledByProp, {
346378
manifestOptions: { appHomeFromConfig: appHome },
347379
onProgress: (completed, total) => {
348380
if (total > 0 && completed % 25 === 0) {

src/core/manifest.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,24 @@ export interface BuildManifestOptions {
2525
homeScreenNameOverride?: string;
2626
}
2727

28-
/** Preserve existing manifest entries by name; only add minimal { name } for new ones. */
28+
/** Preserve existing manifest entries by name and order; only add minimal { name } for new ones. */
2929
function mergeByName<T extends { name: string }>(
3030
existing: T[] | undefined,
3131
cloudNames: string[]
3232
): T[] {
33-
const existingByName = new Map((existing ?? []).map((e) => [e.name, e]));
34-
return cloudNames.map((name) => existingByName.get(name) ?? ({ name } as T));
33+
const existingList = existing ?? [];
34+
const cloudNameSet = new Set(cloudNames);
35+
36+
// 1. Keep existing entries that still exist in cloud, in the same order as manifest.
37+
const keptExisting: T[] = existingList.filter((e) => cloudNameSet.has(e.name));
38+
39+
// 2. Append any new cloud names that are not already present.
40+
const keptNames = new Set(keptExisting.map((e) => e.name));
41+
const appended: T[] = cloudNames
42+
.filter((name) => !keptNames.has(name))
43+
.map((name) => ({ name }) as T);
44+
45+
return [...keptExisting, ...appended];
3546
}
3647

3748
export function buildManifestObject(

0 commit comments

Comments
 (0)