Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,19 @@ ensemble update

## Commands

| Command | Description |
| ------------------ | ----------------------------------------------------------------------------- |
| `ensemble login` | Log in to Ensemble (opens browser) |
| `ensemble logout` | Log out and clear local auth session |
| `ensemble token` | Print token for CI (set as `ENSEMBLE_TOKEN`); run `ensemble login` first |
| `ensemble init` | Initialize or update `ensemble.config.json` in the project |
| `ensemble push` | Scan the app directory and push changes to the cloud |
| `ensemble pull` | Pull artifacts from the cloud and overwrite local files |
| `ensemble release` | Manage releases (snapshots) of your app (interactive menu or subcommands) |
| `ensemble add` | Add a new screen, widget, script, action, translation, or asset |
| `ensemble enable` | Enable starter modules (camera, location, google_maps, etc.) in a Flutter app |
| `ensemble test` | Run declarative YAML tests in a Flutter starter project |
| `ensemble update` | Update the CLI to the latest version |
| Command | Description |
| ------------------ | ----------------------------------------------------------------------------------------- |
| `ensemble login` | Log in to Ensemble (opens browser) |
| `ensemble logout` | Log out and clear local auth session |
| `ensemble token` | Print token for CI (set as `ENSEMBLE_TOKEN`); run `ensemble login` first |
| `ensemble init` | Initialize or update `ensemble.config.json` in the project |
| `ensemble push` | Scan the app directory and push changes to the cloud (also syncs the app manifest to CDN) |
| `ensemble pull` | Pull artifacts from the cloud and overwrite local files |
| `ensemble release` | Manage releases (snapshots) of your app (interactive menu or subcommands) |
| `ensemble add` | Add a new screen, widget, script, action, translation, or asset |
| `ensemble enable` | Enable starter modules (camera, location, google_maps, etc.) in a Flutter app |
| `ensemble test` | Run declarative YAML tests in a Flutter starter project |
| `ensemble update` | Update the CLI to the latest version |

### Options

Expand Down Expand Up @@ -154,7 +154,7 @@ Run YAML tests in a Flutter starter project (starter root or `ensemble/apps/<app

1. Log in: `ensemble login`
2. From your project root, run `ensemble init` and link an existing app
3. Run `ensemble push` to sync your local app (screens, widgets, scripts, etc.) with the cloud
3. Run `ensemble push` to sync your local app (screens, widgets, scripts, etc.) with the cloud and publish the CDN manifest (for apps using `definitions.from: cdn` in `ensemble-config.yaml`)
4. Optionally run `ensemble pull` to refresh local artifacts from the cloud when other collaborators change them

### Environment files
Expand Down
84 changes: 84 additions & 0 deletions src/cloud/cdnClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
export class CdnClientError extends Error {
status?: number;
hint?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cause?: any;

constructor(params: { message: string; status?: number; hint?: string; cause?: unknown }) {
super(params.message);
this.name = 'CdnClientError';
this.status = params.status;
this.hint = params.hint;
this.cause = params.cause;
}
}

const CREATE_APP_MANIFEST_URL =
'https://us-central1-ensemble-web-studio.cloudfunctions.net/studio-createAppManifest';

function parseCreateAppManifestResponse(raw: unknown): void {
if (raw === null || raw === undefined || raw === '') {
return;
}

const candidate =
typeof raw === 'object' && raw !== null && 'result' in raw
? (raw as { result?: unknown }).result
: raw;

if (candidate === null || candidate === undefined || candidate === '') {
return;
}

if (typeof candidate !== 'object') {
throw new CdnClientError({
message: 'CDN sync response is invalid.',
cause: raw,
});
}

const success = (candidate as { success?: unknown }).success;
if (success === false) {
throw new CdnClientError({
message: 'CDN sync failed.',
cause: raw,
});
}
}

export async function createAppManifest(appId: string, idToken: string): Promise<void> {
const res = await fetch(CREATE_APP_MANIFEST_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${idToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: {
appId,
},
}),
});

const text = await res.text();
let parsed: unknown = {};
try {
parsed = text ? (JSON.parse(text) as unknown) : {};
} catch {
parsed = { raw: text };
}

if (!res.ok) {
throw new CdnClientError({
message: `CDN sync failed (${res.status}).`,
status: res.status,
hint:
res.status === 401 || res.status === 403
? 'Authentication/authorization failed for CDN sync. Run `ensemble login` and retry.'
: undefined,
cause: parsed,
});
}

parseCreateAppManifestResponse(parsed);
}
15 changes: 15 additions & 0 deletions src/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'fs/promises';
import path from 'path';
import prompts from 'prompts';

import { createAppManifest, CdnClientError } from '../cloud/cdnClient.js';
import {
checkAppAccess,
fetchCloudApp,
Expand Down Expand Up @@ -510,13 +511,16 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
await writeEnvFile(root, envLocal.configWriteFile, pendingLocalEnvConfigWrite);
}

let cloudWritten = false;

if (yamlChangeTotal > 0 || assetsToUpload.length > 0 || assetsToArchive.length > 0) {
const { assetsUploaded } = await withSpinner('Pushing changes to cloud...', () =>
submitCliPush(appId, idToken, pushPayload, firestoreOptions, {
projectRoot: root,
...(assetsToUpload.length > 0 && { assetFileNames: assetsToUpload }),
})
);
cloudWritten = true;
if (assetsUploaded > 0) {
ui.success(`Uploaded ${assetsUploaded} asset(s) and updated .env.config.`);
}
Expand All @@ -534,6 +538,11 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
firestoreOptions
)
);
cloudWritten = true;
}

if (cloudWritten) {
await withSpinner('Syncing to CDN...', () => createAppManifest(appId, idToken));
}

if (manifestNeedsRefresh && bundle) {
Expand Down Expand Up @@ -563,6 +572,12 @@ export async function pushCommand(options: PushOptions = {}): Promise<void> {
} else if (err.code === 'NETWORK_UNAVAILABLE') {
console.error('Network error. Check your internet connection or proxy settings.');
}
} else if (err instanceof CdnClientError) {
console.error(err.message);
if (err.hint) {
console.error(err.hint);
}
console.error('Cloud push succeeded, but CDN sync failed.');
} else {
const message = err instanceof Error ? err.message : String(err);
console.error(message);
Expand Down
77 changes: 77 additions & 0 deletions tests/cloud/cdnClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it } from 'vitest';

import { createAppManifest, CdnClientError } from '../../src/cloud/cdnClient.js';

describe('cdnClient', () => {
const originalFetch = globalThis.fetch;

afterEach(() => {
globalThis.fetch = originalFetch;
});

it('posts expected payload and accepts direct success response', async () => {
let captured: { url: string; method?: string; headers?: HeadersInit; body?: 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;
captured = {
url: urlStr,
method: init?.method,
headers: init?.headers,
body: init?.body as string | undefined,
};
return new Response(JSON.stringify({ success: true }), { status: 200 });
}) as unknown as typeof fetch;

await createAppManifest('app-1', 'id-token');

expect(captured).not.toBeNull();
expect(captured!.url).toContain('createAppManifest');
expect(captured!.method).toBe('POST');
const headers = new Headers(captured!.headers);
expect(headers.get('Authorization')).toBe('Bearer id-token');
expect(headers.get('Content-Type')).toBe('application/json');
expect(captured!.body).toBe(JSON.stringify({ data: { appId: 'app-1' } }));
});

it('parses callable-style result wrapper', async () => {
globalThis.fetch = (async () => {
return new Response(JSON.stringify({ result: { success: true } }), { status: 200 });
}) as unknown as typeof fetch;

await expect(createAppManifest('app-1', 'id-token')).resolves.toBeUndefined();
});

it('accepts empty 200 response body', async () => {
globalThis.fetch = (async () => {
return new Response('', { status: 200 });
}) as unknown as typeof fetch;

await expect(createAppManifest('app-1', 'id-token')).resolves.toBeUndefined();
});

it('throws when success is false', async () => {
globalThis.fetch = (async () => {
return new Response(JSON.stringify({ success: false }), { status: 200 });
}) as unknown as typeof fetch;

await expect(createAppManifest('app-1', 'id-token')).rejects.toThrow(CdnClientError);
});

it('throws on non-2xx with auth hint for 403', async () => {
globalThis.fetch = (async () => {
return new Response(JSON.stringify({ error: 'forbidden' }), { status: 403 });
}) as unknown as typeof fetch;

await expect(createAppManifest('app-1', 'id-token')).rejects.toMatchObject({
message: 'CDN sync failed (403).',
status: 403,
hint: expect.stringContaining('ensemble login'),
});
});
});
72 changes: 70 additions & 2 deletions tests/commands/pushPull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,16 @@ const cloudModuleMock = vi.hoisted(() => {
};
});

vi.mock('../../src/cloud/firestoreClient.js', () => cloudModuleMock);
vi.mock('../../src/cloud/firestoreClient.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/cloud/firestoreClient.js')>();
return {
...actual,
checkAppAccess: cloudModuleMock.checkAppAccess,
fetchCloudApp: cloudModuleMock.fetchCloudApp,
submitCliPush: cloudModuleMock.submitCliPush,
submitEnvDocumentsPush: cloudModuleMock.submitEnvDocumentsPush,
};
});

const assetClientMock = vi.hoisted(() => ({
uploadAssetToStudio: vi.fn(async (_appId: string, fileName: string) => ({
Expand All @@ -102,6 +111,18 @@ const assetClientMock = vi.hoisted(() => ({

vi.mock('../../src/cloud/assetClient.js', () => assetClientMock);

const cdnClientMock = vi.hoisted(() => ({
createAppManifest: vi.fn(async () => {}),
}));

vi.mock('../../src/cloud/cdnClient.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/cloud/cdnClient.js')>();
return {
...actual,
createAppManifest: cdnClientMock.createAppManifest,
};
});

const promptsModuleMock = vi.hoisted(() => ({
default: vi.fn(async () => ({ proceed: true })),
}));
Expand Down Expand Up @@ -165,6 +186,8 @@ describe('push/pull integration (commands)', () => {
expect(submitCliPush).toHaveBeenCalledTimes(1);
const [appId, , payload] = submitCliPush.mock.calls[0] as [string, string, unknown];
expect(appId).toBe('app1');
expect(cdnClientMock.createAppManifest).toHaveBeenCalledTimes(1);
expect(cdnClientMock.createAppManifest).toHaveBeenCalledWith('app1', 'token');
const p = payload as {
translations?: {
operation: string;
Expand Down Expand Up @@ -276,6 +299,7 @@ describe('push/pull integration (commands)', () => {
submitCliPush: ReturnType<typeof vi.fn>;
};
expect(submitCliPush).not.toHaveBeenCalled();
expect(cdnClientMock.createAppManifest).not.toHaveBeenCalled();

// Dry run output should clearly indicate non-destructive behavior and how to apply.
const lines = logSpy.mock.calls.map(([msg]) => String(msg));
Expand Down Expand Up @@ -544,6 +568,7 @@ describe('push/pull integration (commands)', () => {
await pushCommand({ verbose: false, yes: true });

expect(submitCliPush).not.toHaveBeenCalled();
expect(cdnClientMock.createAppManifest).not.toHaveBeenCalled();
const messages = logSpy.mock.calls.map((args) => args[0]);
expect(
messages.some(
Expand Down Expand Up @@ -593,6 +618,7 @@ describe('push/pull integration (commands)', () => {
await pushCommand({ verbose: false, yes: true });

expect(submitCliPush).not.toHaveBeenCalled();
expect(cdnClientMock.createAppManifest).not.toHaveBeenCalled();
const messages = logSpy.mock.calls.map((args) => args[0]);
expect(
messages.some(
Expand All @@ -602,7 +628,6 @@ describe('push/pull integration (commands)', () => {

logSpy.mockRestore();
});

it('pull respects app options and does not overwrite disabled artifact kinds', async () => {
// Disable screens in app options
appOptionsRef.value = { screens: false };
Expand Down Expand Up @@ -1039,6 +1064,49 @@ describe('push/pull integration (commands)', () => {
)[2];
expect(payload.config?.envVariables?.API_URL).toBe('https://local.example.com');
expect(payload.secrets?.secrets?.S1).toBe('local-secret');
expect(cdnClientMock.createAppManifest).toHaveBeenCalledTimes(1);
expect(cdnClientMock.createAppManifest).toHaveBeenCalledWith('app1', 'token');
});

it('push fails when CDN sync fails after cloud push', async () => {
await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8');
await fs.writeFile(path.join(projectRoot, 'translations', 'en.yaml'), 'en: content', 'utf8');

(cloudModuleMock.fetchCloudApp as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
id: 'app1',
name: 'App',
screens: [],
widgets: [],
scripts: [],
translations: [],
theme: undefined,
});

const { CdnClientError } = await import('../../src/cloud/cdnClient.js');
cdnClientMock.createAppManifest.mockRejectedValueOnce(
new CdnClientError({ message: 'CDN sync failed (500).' })
);

const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const originalExitCode = process.exitCode;

await pushCommand({ yes: true });

expect(cloudModuleMock.submitCliPush).toHaveBeenCalledTimes(1);
expect(cdnClientMock.createAppManifest).toHaveBeenCalledTimes(1);
expect(process.exitCode).toBe(1);
const errors = errorSpy.mock.calls.map(([msg]) => String(msg)).join('\n');
expect(errors).toContain('Cloud push succeeded, but CDN sync failed.');
expect(
logSpy.mock.calls.some(
([msg]) => typeof msg === 'string' && msg.includes('Pushed app "App" to environment "dev"')
)
).toBe(false);

process.exitCode = originalExitCode;
errorSpy.mockRestore();
logSpy.mockRestore();
});

it('push clears cloud secrets when .env.secrets is empty', async () => {
Expand Down
Loading