@@ -3,10 +3,14 @@ import path from 'path';
33import prompts from 'prompts' ;
44
55import { 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' ;
69import { upsertManifestEntry , type RootManifest } from '../core/manifest.js' ;
710import { 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
1115function 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+
55153async 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
0 commit comments