Skip to content

Commit 8d5dcec

Browse files
committed
feat: add asset management capabilities to CLI
- Introduced `ensemble add asset` command to facilitate the addition of assets, including file uploads to the cloud. - Updated README to include new asset command details and usage instructions. - Enhanced error handling for asset uploads and updated environment configuration management. - Added tests for asset upload functionality and integration with existing commands.
1 parent 848856e commit 8d5dcec

6 files changed

Lines changed: 461 additions & 12 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ To release a new version, go to GitHub → Actions → run the workflow **Releas
8080
| `ensemble push` | Scan the app directory and push changes to the cloud |
8181
| `ensemble pull` | Pull artifacts from the cloud and overwrite local files |
8282
| `ensemble release` | Manage releases (snapshots) of your app (interactive menu or subcommands) |
83-
| `ensemble add` | Add a new screen, widget, script, action, or translation scaffold |
83+
| `ensemble add` | Add a new screen, widget, script, action, translation, or asset |
8484
| `ensemble update` | Update the CLI to the latest version |
8585

8686
### Options
@@ -116,6 +116,7 @@ To release a new version, go to GitHub → Actions → run the workflow **Releas
116116
- `script`
117117
- `action`
118118
- `translation`
119+
- `asset`
119120

120121
- **Usage**
121122
- Interactive (prompts for kind and name):
@@ -132,6 +133,7 @@ To release a new version, go to GitHub → Actions → run the workflow **Releas
132133
ensemble add script myUtility
133134
ensemble add action ShowToast
134135
ensemble add translation en_US
136+
ensemble add asset ./logo.png
135137
```
136138

137139
- **Naming rules**
@@ -144,6 +146,14 @@ To release a new version, go to GitHub → Actions → run the workflow **Releas
144146
- Actions: `actions/<Name>.yaml`
145147
- Scripts: `scripts/<Name>.js`
146148
- Translations: `translations/<Name>.yaml`
149+
- Assets: `assets/<FileName>`
150+
151+
- **Asset upload behavior**
152+
- `asset` expects a file path (not a generated scaffold name).
153+
- The file is copied to `assets/`, uploaded to Ensemble cloud, and `.env.config` is upserted:
154+
- `assets=<assetBaseUrl>` is added only if missing.
155+
- The cloud-provided env variable key/value is added (or updated).
156+
- The CLI prints the returned usage key so you can paste it directly in app definitions.
147157

148158
- **Manifest behavior**
149159
- For `widget`, `script`, `action`, and `translation`, `.manifest.json` is updated to include the new artifact.

src/cloud/assetClient.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
export class AssetClientError extends Error {
2+
status?: number;
3+
hint?: string;
4+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5+
cause?: any;
6+
7+
constructor(params: { message: string; status?: number; hint?: string; cause?: unknown }) {
8+
super(params.message);
9+
this.name = 'AssetClientError';
10+
this.status = params.status;
11+
this.hint = params.hint;
12+
this.cause = params.cause;
13+
}
14+
}
15+
16+
export interface UploadAssetResponse {
17+
success: boolean;
18+
assetBaseUrl: string;
19+
envVariable: {
20+
key: string;
21+
value: string;
22+
};
23+
usageKey: string;
24+
}
25+
26+
const STUDIO_UPLOAD_ASSET_URL =
27+
'https://us-central1-ensemble-web-studio.cloudfunctions.net/studio-uploadAsset';
28+
29+
function parseUploadAssetResponse(raw: unknown): UploadAssetResponse {
30+
const candidate =
31+
typeof raw === 'object' && raw !== null && 'result' in raw
32+
? (raw as { result?: unknown }).result
33+
: raw;
34+
if (
35+
!candidate ||
36+
typeof candidate !== 'object' ||
37+
typeof (candidate as { success?: unknown }).success !== 'boolean'
38+
) {
39+
throw new AssetClientError({
40+
message: 'Asset upload response is invalid.',
41+
cause: raw,
42+
});
43+
}
44+
const payload = candidate as {
45+
success: boolean;
46+
assetBaseUrl?: unknown;
47+
envVariable?: unknown;
48+
usageKey?: unknown;
49+
};
50+
if (!payload.success) {
51+
throw new AssetClientError({
52+
message: 'Asset upload failed.',
53+
cause: raw,
54+
});
55+
}
56+
const envVariable = payload.envVariable as { key?: unknown; value?: unknown } | undefined;
57+
if (
58+
typeof payload.assetBaseUrl !== 'string' ||
59+
!envVariable ||
60+
typeof envVariable.key !== 'string' ||
61+
typeof envVariable.value !== 'string' ||
62+
typeof payload.usageKey !== 'string'
63+
) {
64+
throw new AssetClientError({
65+
message: 'Asset upload response is missing required fields.',
66+
cause: raw,
67+
});
68+
}
69+
return {
70+
success: payload.success,
71+
assetBaseUrl: payload.assetBaseUrl,
72+
envVariable: {
73+
key: envVariable.key,
74+
value: envVariable.value,
75+
},
76+
usageKey: payload.usageKey,
77+
};
78+
}
79+
80+
export async function uploadAssetToStudio(
81+
appId: string,
82+
fileName: string,
83+
fileDataBase64: string,
84+
idToken: string
85+
): Promise<UploadAssetResponse> {
86+
const res = await fetch(STUDIO_UPLOAD_ASSET_URL, {
87+
method: 'POST',
88+
headers: {
89+
Authorization: `Bearer ${idToken}`,
90+
'Content-Type': 'application/json',
91+
},
92+
body: JSON.stringify({
93+
data: {
94+
appId,
95+
fileName,
96+
fileData: fileDataBase64,
97+
},
98+
}),
99+
});
100+
101+
const text = await res.text();
102+
let parsed: unknown = {};
103+
try {
104+
parsed = text ? (JSON.parse(text) as unknown) : {};
105+
} catch {
106+
parsed = { raw: text };
107+
}
108+
109+
if (!res.ok) {
110+
throw new AssetClientError({
111+
message: `Asset upload failed (${res.status}).`,
112+
status: res.status,
113+
hint:
114+
res.status === 401 || res.status === 403
115+
? 'Authentication/authorization failed for asset upload. Run `ensemble login` and retry.'
116+
: undefined,
117+
cause: parsed,
118+
});
119+
}
120+
121+
return parseUploadAssetResponse(parsed);
122+
}

src/commands/add.ts

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ import path from 'path';
33
import prompts from 'prompts';
44

55
import { loadProjectConfig } from '../config/projectConfig.js';
6+
import { resolveAppContext } from '../config/projectConfig.js';
7+
import { getValidAuthSession } from '../auth/session.js';
8+
import { uploadAssetToStudio } from '../cloud/assetClient.js';
69
import { upsertManifestEntry, type RootManifest } from '../core/manifest.js';
710
import { ui } from '../core/ui.js';
11+
import { withSpinner } from '../lib/spinner.js';
812

9-
export type AddKind = 'screen' | 'widget' | 'script' | 'action' | 'translation';
13+
export type AddKind = 'screen' | 'widget' | 'script' | 'action' | 'translation' | 'asset';
1014

1115
function normalizeName(raw: string): string {
1216
const trimmed = raw.trim();
@@ -52,6 +56,100 @@ async function fileExists(filePath: string): Promise<boolean> {
5256
}
5357
}
5458

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+
106+
async function addAsset(
107+
projectRoot: string,
108+
assetPathInput: string
109+
): Promise<{
110+
fileName: string;
111+
createdPath: string;
112+
usageKey: string;
113+
}> {
114+
const resolvedInputPath = path.resolve(projectRoot, assetPathInput.trim());
115+
const sourceStat = await fs.stat(resolvedInputPath).catch(() => null);
116+
if (!sourceStat || !sourceStat.isFile()) {
117+
throw new Error(`Asset path does not exist or is not a file: ${assetPathInput}`);
118+
}
119+
const fileName = path.basename(resolvedInputPath);
120+
const targetDir = path.join(projectRoot, 'assets');
121+
await ensureDir(targetDir);
122+
const targetPath = path.join(targetDir, fileName);
123+
if (await fileExists(targetPath)) {
124+
throw new Error(`File already exists: ${path.relative(projectRoot, targetPath)}`);
125+
}
126+
127+
await fs.copyFile(resolvedInputPath, targetPath);
128+
129+
const fileBuffer = await fs.readFile(targetPath);
130+
const fileDataBase64 = fileBuffer.toString('base64');
131+
132+
const { appId } = await resolveAppContext();
133+
const session = await getValidAuthSession();
134+
if (!session.ok) {
135+
throw new Error(`${session.message}\nRun \`ensemble login\` and try again.`);
136+
}
137+
const uploadResult = await withSpinner('Uploading asset to cloud...', async () => {
138+
const result = await uploadAssetToStudio(appId, fileName, fileDataBase64, session.idToken);
139+
await upsertEnvConfig(projectRoot, [
140+
{ key: 'assets', value: result.assetBaseUrl, overwrite: false },
141+
{ key: result.envVariable.key, value: result.envVariable.value },
142+
]);
143+
return result;
144+
});
145+
146+
return {
147+
fileName,
148+
createdPath: path.relative(projectRoot, targetPath),
149+
usageKey: uploadResult.usageKey,
150+
};
151+
}
152+
55153
async function maybeSetHomeScreenName(
56154
projectRoot: string,
57155
screenName: string,
@@ -165,6 +263,7 @@ export async function addCommand(kindArg?: AddKind, rawNameArg?: string): Promis
165263
{ title: 'Script', value: 'script' },
166264
{ title: 'Action', value: 'action' },
167265
{ title: 'Translation', value: 'translation' },
266+
{ title: 'Asset', value: 'asset' },
168267
],
169268
});
170269
if (!selected) {
@@ -182,8 +281,8 @@ export async function addCommand(kindArg?: AddKind, rawNameArg?: string): Promis
182281
const { name } = await prompts({
183282
type: 'text',
184283
name: 'name',
185-
message: `Name for the ${kind}:`,
186-
validate: (v: string) => (v && v.trim().length > 0 ? true : 'Name is required'),
284+
message: kind === 'asset' ? 'Path for the asset file:' : `Name for the ${kind}:`,
285+
validate: (v: string) => (v && v.trim().length > 0 ? true : 'Value is required'),
187286
});
188287
if (!name) {
189288
ui.warn('Add cancelled.');
@@ -196,9 +295,16 @@ export async function addCommand(kindArg?: AddKind, rawNameArg?: string): Promis
196295
throw new Error('Name is required.');
197296
}
198297

298+
const { projectRoot } = await loadProjectConfig();
299+
if (kind === 'asset') {
300+
const { fileName, createdPath, usageKey } = await addAsset(projectRoot, rawName);
301+
ui.success(`Created asset "${fileName}" at ${createdPath} and updated .env.config.`);
302+
ui.note(`Usage Example: ${usageKey}`);
303+
return;
304+
}
305+
199306
let name = normalizeName(rawName);
200307
name = await resolveNameWithSpaces(name, interactive);
201-
const { projectRoot } = await loadProjectConfig();
202308

203309
let targetDir: string;
204310
let fileName: string;
@@ -238,7 +344,7 @@ export async function addCommand(kindArg?: AddKind, rawNameArg?: string): Promis
238344
default:
239345
// This should be unreachable if commander validates input.
240346
throw new Error(
241-
`Unknown artifact type "${kind}". Expected one of: screen, widget, script, translation.`
347+
`Unknown artifact type "${kind}". Expected one of: screen, widget, script, action, translation, asset.`
242348
);
243349
}
244350

src/index.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,18 +174,32 @@ releaseCmd.action(async (options: { app?: string }) => {
174174

175175
program
176176
.command('add')
177-
.description('Add a new screen, widget, script, or translation.')
178-
.argument('[kind]', 'Artifact type: screen | widget | script | translation')
179-
.argument('[name]', 'Name of the artifact, e.g. "Hello"')
177+
.description('Add a new screen, widget, script, action, translation, or asset.')
178+
.argument('[kind]', 'Artifact type: screen | widget | script | action | translation | asset')
179+
.argument('[name]', 'Name/path of the artifact (asset expects a file path)')
180180
.action(async (kind?: string, name?: string) => {
181-
let normalizedKind: 'screen' | 'widget' | 'script' | 'translation' | undefined;
181+
let normalizedKind:
182+
| 'screen'
183+
| 'widget'
184+
| 'script'
185+
| 'action'
186+
| 'translation'
187+
| 'asset'
188+
| undefined;
182189
if (kind) {
183190
const k = kind.toLowerCase();
184-
if (k === 'screen' || k === 'widget' || k === 'script' || k === 'translation') {
191+
if (
192+
k === 'screen' ||
193+
k === 'widget' ||
194+
k === 'script' ||
195+
k === 'action' ||
196+
k === 'translation' ||
197+
k === 'asset'
198+
) {
185199
normalizedKind = k;
186200
} else {
187201
throw new Error(
188-
`Unknown artifact type "${kind}". Expected one of: screen, widget, script, translation.`
202+
`Unknown artifact type "${kind}". Expected one of: screen, widget, script, action, translation, asset.`
189203
);
190204
}
191205
}

0 commit comments

Comments
 (0)