@@ -298,6 +298,9 @@ function renderStudioHtml(): string {
298298 .sectionTitle { font-size: 14px; font-weight: 700; margin-top: 10px; }
299299 .stack { display:flex; flex-direction:column; gap:8px; }
300300 .toolbar { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
301+ .configList { display:flex; flex-direction:column; gap:8px; max-height:260px; overflow:auto; }
302+ .configRow { display:grid; grid-template-columns: 1fr auto; gap:8px; align-items:center; border:1px solid rgba(255,255,255,0.12); border-radius:8px; padding:8px; background: rgba(0,0,0,0.2); }
303+ .configPath { font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
301304 button { border:1px solid rgba(255,255,255,0.2); color:#fff; background:#1e3357; border-radius:8px; padding:8px 10px; cursor:pointer; }
302305 button:hover { filter: brightness(1.08); }
303306 .pill { display:inline-block; padding: 2px 8px; border-radius:999px; font-size: 12px; border:1px solid transparent; }
@@ -313,6 +316,20 @@ function renderStudioHtml(): string {
313316 <div class="wrap">
314317 <h1 style="margin:0 0 8px 0;">Token Host Studio (Local)</h1>
315318 <div class="muted" style="margin-bottom:14px;">Edit THS JSON, validate/lint in real-time, save/load files, and preview routes + contract surface.</div>
319+ <section class="panel" style="margin-bottom:14px;">
320+ <h2 class="title">Config Manager</h2>
321+ <div class="grid3">
322+ <div><label for="newConfigName">App name</label><input id="newConfigName" type="text" placeholder="My App" /></div>
323+ <div><label for="newConfigSlug">App slug</label><input id="newConfigSlug" type="text" placeholder="my-app" /></div>
324+ <div><label for="newConfigPath">Path (optional)</label><input id="newConfigPath" type="text" placeholder="apps/my-app/schema.json" /></div>
325+ </div>
326+ <div class="toolbar" style="margin-top:8px;">
327+ <button id="refreshConfigsBtn">Refresh Configs</button>
328+ <button id="createConfigBtn">Create New Config</button>
329+ <span id="configsStatus" class="muted"></span>
330+ </div>
331+ <div id="configsList" class="configList"></div>
332+ </section>
316333 <div class="row">
317334 <section class="panel">
318335 <h2 class="title">Schema Builder</h2>
@@ -342,6 +359,13 @@ function renderStudioHtml(): string {
342359 </div>
343360 <script>
344361 const schemaPathEl = document.getElementById('schemaPath');
362+ const newConfigNameEl = document.getElementById('newConfigName');
363+ const newConfigSlugEl = document.getElementById('newConfigSlug');
364+ const newConfigPathEl = document.getElementById('newConfigPath');
365+ const refreshConfigsBtnEl = document.getElementById('refreshConfigsBtn');
366+ const createConfigBtnEl = document.getElementById('createConfigBtn');
367+ const configsStatusEl = document.getElementById('configsStatus');
368+ const configsListEl = document.getElementById('configsList');
345369 const formRootEl = document.getElementById('formRoot');
346370 const statusLineEl = document.getElementById('statusLine');
347371 const issuesEl = document.getElementById('issues');
@@ -350,6 +374,7 @@ function renderStudioHtml(): string {
350374 let timer = null;
351375 let selectedCollectionIndex = 0;
352376 let state = null;
377+ let workspaceRoot = '';
353378 const fieldTypes = ['string','uint256','int256','decimal','bool','address','bytes32','image','reference','externalReference'];
354379 const accessModes = ['public','owner','allowlist','role'];
355380
@@ -359,6 +384,52 @@ function renderStudioHtml(): string {
359384 statusLineEl.innerHTML = '<span class=\"pill ' + cls + '\">' + label + '</span> <span class=\"muted\">issues: ' + issueCount + '</span>';
360385 }
361386
387+ function relativePathForDisplay(filePath) {
388+ if (!workspaceRoot) return String(filePath || '');
389+ const normalizedRoot = workspaceRoot.endsWith('/') ? workspaceRoot : (workspaceRoot + '/');
390+ if (String(filePath || '').startsWith(normalizedRoot)) {
391+ return String(filePath).slice(normalizedRoot.length);
392+ }
393+ return String(filePath || '');
394+ }
395+
396+ async function loadPathIntoStudio(targetPath) {
397+ const out = await postJson('/api/load', { path: targetPath });
398+ schemaPathEl.value = out.path || schemaPathEl.value;
399+ state = out.formState || state;
400+ selectedCollectionIndex = 0;
401+ renderForm();
402+ queueValidation();
403+ }
404+
405+ function renderConfigsList(configs) {
406+ if (!Array.isArray(configs) || configs.length === 0) {
407+ configsListEl.innerHTML = '<div class="muted">No schema configs found under workspace root.</div>';
408+ return;
409+ }
410+ configsListEl.innerHTML = configs.map((cfg) => (
411+ '<div class="configRow">' +
412+ '<div class="configPath" title="' + esc(cfg) + '">' + esc(relativePathForDisplay(cfg)) + '</div>' +
413+ '<button data-action="load-config" data-path="' + esc(cfg) + '">Load</button>' +
414+ '</div>'
415+ )).join('');
416+ }
417+
418+ async function refreshConfigs() {
419+ configsStatusEl.textContent = 'Refreshing...';
420+ try {
421+ const res = await fetch('/api/configs', { cache: 'no-store' });
422+ const json = await res.json();
423+ if (!res.ok || !json || !json.ok) throw new Error(json && json.error ? json.error : ('HTTP ' + res.status));
424+ workspaceRoot = String(json.workspaceRoot || workspaceRoot || '');
425+ renderConfigsList(json.configs || []);
426+ configsStatusEl.textContent = (json.configs || []).length + ' config(s)';
427+ } catch (e) {
428+ configsStatusEl.textContent = 'Failed to load configs';
429+ renderConfigsList([]);
430+ }
431+ }
432+
362433 function renderIssues(issues) {
363434 if (!issues || issues.length === 0) {
364435 issuesEl.innerHTML = '<span class=\"pill ok\">No issues</span>';
@@ -633,12 +704,7 @@ function renderStudioHtml(): string {
633704 document.getElementById('validateBtn').addEventListener('click', runValidation);
634705 document.getElementById('loadBtn').addEventListener('click', async () => {
635706 try {
636- const out = await postJson('/api/load', { path: schemaPathEl.value });
637- schemaPathEl.value = out.path || schemaPathEl.value;
638- state = out.formState || state;
639- selectedCollectionIndex = 0;
640- renderForm();
641- queueValidation();
707+ await loadPathIntoStudio(schemaPathEl.value);
642708 } catch (e) {
643709 alert(String(e && e.message ? e.message : e));
644710 }
@@ -660,18 +726,54 @@ function renderStudioHtml(): string {
660726 renderForm();
661727 queueValidation();
662728 });
729+ refreshConfigsBtnEl.addEventListener('click', async (ev) => {
730+ ev.preventDefault();
731+ await refreshConfigs();
732+ });
733+ createConfigBtnEl.addEventListener('click', async (ev) => {
734+ ev.preventDefault();
735+ try {
736+ const out = await postJson('/api/create-config', {
737+ name: newConfigNameEl.value,
738+ slug: newConfigSlugEl.value,
739+ path: newConfigPathEl.value
740+ });
741+ schemaPathEl.value = out.path || '';
742+ state = out.formState || state;
743+ selectedCollectionIndex = 0;
744+ renderForm();
745+ queueValidation();
746+ await refreshConfigs();
747+ } catch (e) {
748+ alert(String(e && e.message ? e.message : e));
749+ }
750+ });
751+ configsListEl.addEventListener('click', async (ev) => {
752+ const target = ev.target;
753+ if (!target || !target.getAttribute) return;
754+ if (target.getAttribute('data-action') !== 'load-config') return;
755+ const configPath = target.getAttribute('data-path');
756+ if (!configPath) return;
757+ try {
758+ await loadPathIntoStudio(configPath);
759+ } catch (e) {
760+ alert(String(e && e.message ? e.message : e));
761+ }
762+ });
663763
664764 (async () => {
665765 try {
666766 const stateRes = await fetch('/api/state', { cache: 'no-store' });
667767 const stateResJson = await stateRes.json();
668768 schemaPathEl.value = stateResJson.schemaPath || '';
769+ workspaceRoot = String(stateResJson.workspaceRoot || '');
669770 state = stateResJson.formState || null;
670771 renderForm();
671772 } catch {
672773 ensureState();
673774 renderForm();
674775 }
776+ await refreshConfigs();
675777 queueValidation();
676778 })();
677779 </script>
@@ -2054,12 +2156,44 @@ program
20542156
20552157 let schemaPath : string | null = opts . schema ? path . resolve ( opts . schema ) : null ;
20562158 let formState : ThsSchema = defaultStudioFormState ( ) ;
2159+ const workspaceRoot = process . cwd ( ) ;
20572160 if ( schemaPath && fs . existsSync ( schemaPath ) ) {
20582161 const loaded = readJsonFile ( schemaPath ) ;
20592162 const structural = validateThsStructural ( loaded ) ;
20602163 if ( structural . ok ) formState = normalizeStudioFormState ( structural . data ) ;
20612164 }
20622165
2166+ function listLocalConfigs ( root : string ) : string [ ] {
2167+ const out : string [ ] = [ ] ;
2168+ const stack = [ root ] ;
2169+ const skipDirs = new Set ( [ '.git' , 'node_modules' , '.next' , 'dist' , 'out' , 'artifacts' , 'cache' ] ) ;
2170+
2171+ while ( stack . length > 0 ) {
2172+ const dir = stack . pop ( ) ! ;
2173+ let entries : fs . Dirent [ ] = [ ] ;
2174+ try {
2175+ entries = fs . readdirSync ( dir , { withFileTypes : true } ) ;
2176+ } catch {
2177+ continue ;
2178+ }
2179+ for ( const entry of entries ) {
2180+ const full = path . join ( dir , entry . name ) ;
2181+ if ( entry . isDirectory ( ) ) {
2182+ if ( skipDirs . has ( entry . name ) ) continue ;
2183+ stack . push ( full ) ;
2184+ continue ;
2185+ }
2186+ if ( ! entry . isFile ( ) ) continue ;
2187+ if ( ! entry . name . endsWith ( '.json' ) ) continue ;
2188+ if ( entry . name === 'schema.json' || entry . name . endsWith ( '.schema.json' ) ) {
2189+ out . push ( path . resolve ( full ) ) ;
2190+ }
2191+ }
2192+ }
2193+
2194+ return Array . from ( new Set ( out ) ) . sort ( ( a , b ) => a . localeCompare ( b ) ) ;
2195+ }
2196+
20632197 function sendText ( res : nodeHttp . ServerResponse , status : number , text : string ) {
20642198 res . statusCode = status ;
20652199 res . setHeader ( 'Content-Type' , 'text/plain; charset=utf-8' ) ;
@@ -2114,7 +2248,16 @@ program
21142248 if ( pathname === '/api/state' ) {
21152249 return sendJson ( res , 200 , {
21162250 schemaPath,
2117- formState
2251+ formState,
2252+ workspaceRoot
2253+ } ) ;
2254+ }
2255+
2256+ if ( pathname === '/api/configs' && req . method === 'GET' ) {
2257+ return sendJson ( res , 200 , {
2258+ ok : true ,
2259+ workspaceRoot,
2260+ configs : listLocalConfigs ( workspaceRoot )
21182261 } ) ;
21192262 }
21202263
@@ -2186,6 +2329,57 @@ program
21862329 return ;
21872330 }
21882331
2332+ if ( pathname === '/api/create-config' && req . method === 'POST' ) {
2333+ ( async ( ) => {
2334+ try {
2335+ const body = await parseJsonBody ( req ) ;
2336+ const slugRaw = String ( body ?. slug ?? 'new-app' ) . trim ( ) ;
2337+ const slug = slugRaw
2338+ . toLowerCase ( )
2339+ . replace ( / [ ^ a - z 0 - 9 - ] / g, '-' )
2340+ . replace ( / - + / g, '-' )
2341+ . replace ( / ^ - | - $ / g, '' ) || 'new-app' ;
2342+ const appName = String ( body ?. name ?? 'New App' ) . trim ( ) || 'New App' ;
2343+ const requestedPath = String ( body ?. path ?? '' ) . trim ( ) ;
2344+ const targetPath = requestedPath
2345+ ? path . resolve ( requestedPath )
2346+ : path . resolve ( workspaceRoot , 'apps' , slug , 'schema.json' ) ;
2347+
2348+ if ( fs . existsSync ( targetPath ) ) {
2349+ return sendJson ( res , 409 , { ok : false , error : `Config already exists: ${ targetPath } ` } ) ;
2350+ }
2351+
2352+ const defaults = defaultStudioFormState ( ) ;
2353+ const createdState = normalizeStudioFormState ( {
2354+ ...defaults ,
2355+ app : {
2356+ ...defaults . app ,
2357+ name : appName ,
2358+ slug
2359+ }
2360+ } ) ;
2361+
2362+ const validated = validateStudioFormState ( createdState ) ;
2363+ if ( ! validated . ok || ! validated . schema ) {
2364+ return sendJson ( res , 400 , {
2365+ ok : false ,
2366+ error : 'Generated config failed validation.' ,
2367+ issues : validated . issues
2368+ } ) ;
2369+ }
2370+
2371+ ensureDir ( path . dirname ( targetPath ) ) ;
2372+ fs . writeFileSync ( targetPath , `${ JSON . stringify ( validated . schema , null , 2 ) } \n` ) ;
2373+ schemaPath = targetPath ;
2374+ formState = normalizeStudioFormState ( validated . schema ) ;
2375+ return sendJson ( res , 200 , { ok : true , path : targetPath , formState, created : true } ) ;
2376+ } catch ( e : any ) {
2377+ return sendJson ( res , 400 , { ok : false , error : String ( e ?. message ?? e ) } ) ;
2378+ }
2379+ } ) ( ) ;
2380+ return ;
2381+ }
2382+
21892383 sendText ( res , 404 , 'Not Found' ) ;
21902384 } ) ;
21912385
0 commit comments