Skip to content

Commit ce5bca1

Browse files
committed
studio: add local config manager with create/load workflow
1 parent b4d53c7 commit ce5bca1

2 files changed

Lines changed: 235 additions & 7 deletions

File tree

packages/cli/src/index.ts

Lines changed: 201 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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-z0-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

test/integration/testStudioCliIntegration.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ describe('th studio local schema builder', function () {
8080
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-studio-'));
8181
const schemaPath = path.join(dir, 'schema.json');
8282
const savedPath = path.join(dir, 'saved.schema.json');
83+
const createdConfigPath = path.resolve(
84+
process.cwd(),
85+
'apps',
86+
`_studio-test-${Date.now()}-${Math.floor(Math.random() * 10000)}`,
87+
'schema.json'
88+
);
8389
fs.writeFileSync(schemaPath, `${JSON.stringify(minimalSchema(), null, 2)}\n`);
8490

8591
const host = '127.0.0.1';
@@ -104,6 +110,12 @@ describe('th studio local schema builder', function () {
104110
expect(state.status).to.equal(200);
105111
expect(state.json?.schemaPath).to.equal(path.resolve(schemaPath));
106112
expect(state.json?.formState?.app?.slug).to.equal('studio-test-app');
113+
expect(state.json?.workspaceRoot).to.equal(process.cwd());
114+
115+
const configsRes = await request(`${baseUrl}/api/configs`);
116+
expect(configsRes.status).to.equal(200);
117+
expect(configsRes.json?.ok).to.equal(true);
118+
expect(Array.isArray(configsRes.json?.configs)).to.equal(true);
107119

108120
const invalidValidation = await request(`${baseUrl}/api/validate`, {
109121
method: 'POST',
@@ -140,8 +152,30 @@ describe('th studio local schema builder', function () {
140152
expect(loadRes.status).to.equal(200);
141153
expect(loadRes.json?.ok).to.equal(true);
142154
expect(loadRes.json?.formState?.app?.slug).to.equal('studio-test-app');
155+
156+
const createRes = await request(`${baseUrl}/api/create-config`, {
157+
method: 'POST',
158+
headers: { 'content-type': 'application/json' },
159+
body: JSON.stringify({
160+
name: 'Created App',
161+
slug: 'created-app',
162+
path: createdConfigPath
163+
})
164+
});
165+
expect(createRes.status).to.equal(200);
166+
expect(createRes.json?.ok).to.equal(true);
167+
expect(createRes.json?.created).to.equal(true);
168+
expect(path.resolve(createRes.json?.path)).to.equal(path.resolve(createdConfigPath));
169+
expect(createRes.json?.formState?.app?.slug).to.equal('created-app');
170+
expect(fs.existsSync(createdConfigPath)).to.equal(true);
171+
expect(fs.readFileSync(createdConfigPath, 'utf-8')).to.include('"created-app"');
172+
173+
const createdConfigsRes = await request(`${baseUrl}/api/configs`);
174+
expect(createdConfigsRes.status).to.equal(200);
175+
expect(createdConfigsRes.json?.configs).to.include(path.resolve(createdConfigPath));
143176
} finally {
144177
studio.kill('SIGINT');
178+
fs.rmSync(path.dirname(createdConfigPath), { recursive: true, force: true });
145179
}
146180
});
147181
});

0 commit comments

Comments
 (0)