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
8 changes: 7 additions & 1 deletion Readme.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
### Token Host Builder

Turns a Token Host Schema (THS) document into deterministic Solidity artifacts (and, later, a generated UI) that can be deployed and self-hosted.
Turns a Token Host Schema (THS) document into deterministic Solidity artifacts and a generated UI bundle that can be deployed and self-hosted.

- Canonical product spec: `SPEC.md`
- Spec-to-code backlog: `AGENTS.md`
Expand All @@ -27,6 +27,12 @@ anvil

# Deploy to local anvil (uses Anvil's default dev key unless ANVIL_PRIVATE_KEY is set)
pnpm th deploy artifacts/job-board --chain anvil

# Serve the generated UI locally (no Python required)
pnpm th preview artifacts/job-board

# Open http://127.0.0.1:3000/
# MetaMask: approve switching/adding the Anvil network (chainId 31337).
```

Environment examples:
Expand Down
237 changes: 231 additions & 6 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import crypto from 'crypto';
import * as nodeHttp from 'node:http';
import { spawnSync } from 'child_process';
import { createRequire } from 'module';
import { fileURLToPath, pathToFileURL } from 'url';
Expand Down Expand Up @@ -89,6 +90,13 @@ function copyDir(srcDir: string, destDir: string) {
}
}

function publishManifestToUiSite(uiSiteDir: string, manifestJson: string) {
ensureDir(uiSiteDir);
ensureDir(path.join(uiSiteDir, '.well-known', 'tokenhost'));
fs.writeFileSync(path.join(uiSiteDir, '.well-known', 'tokenhost', 'manifest.json'), manifestJson);
fs.writeFileSync(path.join(uiSiteDir, 'manifest.json'), manifestJson);
}

function resolveNextExportUiTemplateDir(): string {
const here = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
Expand Down Expand Up @@ -477,6 +485,7 @@ program
`pnpm th validate ${schemaPath}`,
`pnpm th build ${schemaPath} --out ${path.join(outDir, 'build')}`,
`pnpm th deploy ${path.join(outDir, 'build')} --chain anvil`,
`pnpm th preview ${path.join(outDir, 'build')}`,
'```',
''
].join('\n')
Expand Down Expand Up @@ -650,7 +659,7 @@ program
return;
}

const outDir = opts.out;
const outDir = path.resolve(opts.out);
ensureDir(outDir);

// 1) Generate Solidity source
Expand Down Expand Up @@ -831,9 +840,7 @@ program
if (uiBundleDir && uiSiteDir) {
fs.rmSync(uiSiteDir, { recursive: true, force: true });
copyDir(uiBundleDir, uiSiteDir);
ensureDir(path.join(uiSiteDir, '.well-known', 'tokenhost'));
fs.writeFileSync(path.join(uiSiteDir, '.well-known', 'tokenhost', 'manifest.json'), manifestJsonOut);
fs.writeFileSync(path.join(uiSiteDir, 'manifest.json'), manifestJsonOut);
publishManifestToUiSite(uiSiteDir, manifestJsonOut);
}
console.log(`Wrote ${appSol.path}`);
console.log(`Wrote compiled/App.json`);
Expand All @@ -844,12 +851,210 @@ program
console.log(`Wrote ui-site/ (self-hostable static root)`);
}
console.log(`Wrote manifest.json`);

console.log('');
console.log('Next steps:');
console.log(` th deploy ${outDir} --chain anvil # start anvil first`);
console.log(` th deploy ${outDir} --chain sepolia # requires RPC + funded key`);
if (uiBundleDir) {
console.log(` th preview ${outDir} # open http://127.0.0.1:3000/`);
}
});

function anyPaidCreates(schema: ThsSchema): boolean {
return schema.collections.some((c) => Boolean(c.createRules.payment));
}

program
.command('preview')
.argument('<buildDir>', 'Directory created by `th build` (contains ui-site/)')
.description('Serve the generated static UI locally (no Python required)')
.option('--port <n>', 'Port to listen on', '3000')
.option('--host <host>', 'Host to bind (default: 127.0.0.1)', '127.0.0.1')
.action((buildDir: string, opts: { port: string; host: string }) => {
const resolvedBuildDir = path.resolve(buildDir);
const uiSiteDir = path.join(resolvedBuildDir, 'ui-site');

if (!fs.existsSync(uiSiteDir)) {
console.error(`Missing ui-site/ in ${resolvedBuildDir}.`);
console.error('Re-run `th build` without `--no-ui` to generate the UI bundle.');
process.exitCode = 1;
return;
}

const port = Number(opts.port);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
console.error(`Invalid --port: ${opts.port}`);
process.exitCode = 1;
return;
}

const host = String(opts.host || '127.0.0.1');
const rootAbs = path.resolve(uiSiteDir);

function contentTypeForPath(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.html':
return 'text/html; charset=utf-8';
case '.js':
return 'application/javascript; charset=utf-8';
case '.css':
return 'text/css; charset=utf-8';
case '.json':
case '.map':
return 'application/json; charset=utf-8';
case '.svg':
return 'image/svg+xml';
case '.png':
return 'image/png';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.gif':
return 'image/gif';
case '.webp':
return 'image/webp';
case '.ico':
return 'image/x-icon';
case '.woff2':
return 'font/woff2';
case '.woff':
return 'font/woff';
case '.ttf':
return 'font/ttf';
case '.txt':
return 'text/plain; charset=utf-8';
default:
return 'application/octet-stream';
}
}

function sendText(res: nodeHttp.ServerResponse, status: number, text: string) {
res.statusCode = status;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(text);
}

const server = nodeHttp.createServer((req, res) => {
if (!req.url) return sendText(res, 400, 'Bad Request');

if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
res.setHeader('Allow', 'GET, HEAD');
return sendText(res, 405, 'Method Not Allowed');
}

let pathname = '/';
try {
pathname = new URL(req.url, `http://${host}:${port}`).pathname || '/';
} catch {
return sendText(res, 400, 'Bad Request');
}

try {
pathname = decodeURIComponent(pathname);
} catch {
return sendText(res, 400, 'Bad Request');
}

if (!pathname.startsWith('/')) pathname = `/${pathname}`;
const rel = pathname.replace(/^\/+/, '');
const unsafeAbs = path.resolve(rootAbs, rel);
const withinRoot = unsafeAbs === rootAbs || unsafeAbs.startsWith(rootAbs + path.sep);
if (!withinRoot) return sendText(res, 400, 'Bad Request');

// Redirect to trailing-slash routes (Next export uses trailingSlash: true).
if (!pathname.endsWith('/') && fs.existsSync(unsafeAbs) && fs.statSync(unsafeAbs).isDirectory()) {
res.statusCode = 308;
res.setHeader('Location', pathname + '/');
res.setHeader('Cache-Control', 'no-store');
res.end();
return;
}

let filePath = unsafeAbs;
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
filePath = path.join(filePath, 'index.html');
} else if (!fs.existsSync(filePath)) {
// Convenience: allow /foo -> /foo/index.html if present.
const dirIndex = path.join(filePath, 'index.html');
if (fs.existsSync(dirIndex)) {
res.statusCode = 308;
res.setHeader('Location', pathname.endsWith('/') ? pathname : pathname + '/');
res.setHeader('Cache-Control', 'no-store');
res.end();
return;
}

return sendText(res, 404, 'Not Found');
}

try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return sendText(res, 404, 'Not Found');

res.statusCode = 200;
res.setHeader('Content-Type', contentTypeForPath(filePath));
res.setHeader('Content-Length', String(stat.size));

// Disable caching so manifest updates (e.g. after `th deploy`) are reflected immediately.
res.setHeader('Cache-Control', 'no-store');

if (req.method === 'HEAD') {
res.end();
return;
}

fs.createReadStream(filePath).pipe(res);
} catch (e: any) {
return sendText(res, 500, String(e?.message ?? e ?? 'Internal Server Error'));
}
});

server.on('error', (e: any) => {
console.error(String(e?.message ?? e ?? e));
process.exitCode = 1;
});

server.listen(port, host, () => {
const url = `http://${host}:${port}/`;
console.log(`Serving ${uiSiteDir}`);
console.log(url);

const manifestCandidates = [
path.join(uiSiteDir, '.well-known', 'tokenhost', 'manifest.json'),
path.join(uiSiteDir, 'manifest.json'),
path.join(resolvedBuildDir, 'manifest.json')
];
const manifestPath = manifestCandidates.find((p) => fs.existsSync(p)) || null;
if (manifestPath) {
try {
const manifest = readJsonFile(manifestPath) as any;
const deployments = Array.isArray(manifest?.deployments) ? manifest.deployments : [];
const deployment = deployments.find((d: any) => d && d.role === 'primary') ?? deployments[0] ?? null;
const addr = String(deployment?.deploymentEntrypointAddress ?? '');
const chainId = deployment?.chainId ?? null;
console.log(`manifest: ${manifestPath}`);
console.log(`deployment: chainId=${chainId ?? 'unknown'} address=${addr || 'unknown'}`);
const zeroAddress = '0x0000000000000000000000000000000000000000';
if (addr && addr.toLowerCase() === zeroAddress) {
console.log('');
console.log('Not deployed: deploymentEntrypointAddress is 0x0.');
console.log(`Run: th deploy ${resolvedBuildDir} --chain anvil`);
console.log('Then refresh this page.');
}
} catch {
// Ignore manifest parse errors; the UI will surface them at runtime.
}
}
});

process.on('SIGINT', () => {
server.close(() => process.exit(0));
});
});

program
.command('deploy')
.argument('<buildDir>', 'Directory created by `th build` (contains manifest.json)')
Expand Down Expand Up @@ -1006,8 +1211,21 @@ program
throw new Error(`Updated manifest failed validation:\n${JSON.stringify(validation.errors, null, 2)}`);
}

fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
const manifestJsonOut = JSON.stringify(manifest, null, 2);
fs.writeFileSync(manifestPath, manifestJsonOut);
console.log(`Updated ${manifestPath}`);

const uiSiteDir = path.join(resolvedBuildDir, 'ui-site');
if (fs.existsSync(uiSiteDir)) {
publishManifestToUiSite(uiSiteDir, manifestJsonOut);
console.log(`Published manifest to ui-site/`);
}

if (fs.existsSync(uiSiteDir)) {
console.log('');
console.log('Next steps:');
console.log(` th preview ${resolvedBuildDir} # open http://127.0.0.1:3000/`);
}
} catch (e: any) {
console.error(String(e?.message ?? e));
process.exitCode = 1;
Expand Down Expand Up @@ -1271,7 +1489,14 @@ program
return;
}

fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
const manifestJsonOut = JSON.stringify(manifest, null, 2);
fs.writeFileSync(manifestPath, manifestJsonOut);

const uiSiteDir = path.join(resolvedBuildDir, 'ui-site');
if (fs.existsSync(uiSiteDir)) {
publishManifestToUiSite(uiSiteDir, manifestJsonOut);
console.log(`Published manifest to ui-site/`);
}

if (!verified) {
console.error('Verification did not fully succeed.');
Expand Down
14 changes: 11 additions & 3 deletions packages/templates/next-export-ui/app/[collection]/ClientPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,17 @@ export default function CollectionListPage(props: { params: { collection: string
return (
<div className="card">
<h2>Not deployed</h2>
<div className="muted">Run <span className="badge">th deploy</span> and re-publish the manifest for this UI.</div>
<div className="pre">manifest deploymentEntrypointAddress is 0x0</div>
<div className="muted">
This UI reads <span className="badge">/.well-known/tokenhost/manifest.json</span> at runtime, but the manifest still has a placeholder
deployment address (<span className="badge">deploymentEntrypointAddress = 0x0</span>).
</div>
<div className="muted" style={{ marginTop: 12 }}>
Run <span className="badge">th deploy {'<buildDir>'} --chain anvil</span>, then refresh this page.
</div>
<div className="muted" style={{ marginTop: 12 }}>
If you are hosting this UI remotely, publish the updated <span className="badge">manifest.json</span> to{' '}
<span className="badge">/.well-known/tokenhost/manifest.json</span>.
</div>
</div>
);
}
Expand Down Expand Up @@ -231,4 +240,3 @@ export default function CollectionListPage(props: { params: { collection: string
</>
);
}

Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,17 @@ export default function DeleteRecordPage(props: { params: { collection: string }
return (
<div className="card">
<h2>Not deployed</h2>
<div className="muted">Run <span className="badge">th deploy</span> and re-publish the manifest for this UI.</div>
<div className="pre">manifest deploymentEntrypointAddress is 0x0</div>
<div className="muted">
This UI reads <span className="badge">/.well-known/tokenhost/manifest.json</span> at runtime, but the manifest still has a placeholder
deployment address (<span className="badge">deploymentEntrypointAddress = 0x0</span>).
</div>
<div className="muted" style={{ marginTop: 12 }}>
Run <span className="badge">th deploy {'<buildDir>'} --chain anvil</span>, then refresh this page.
</div>
<div className="muted" style={{ marginTop: 12 }}>
If you are hosting this UI remotely, publish the updated <span className="badge">manifest.json</span> to{' '}
<span className="badge">/.well-known/tokenhost/manifest.json</span>.
</div>
</div>
);
}
Expand Down Expand Up @@ -234,4 +243,3 @@ export default function DeleteRecordPage(props: { params: { collection: string }
</div>
);
}

Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,17 @@ export default function EditRecordPage(props: { params: { collection: string } }
return (
<div className="card">
<h2>Not deployed</h2>
<div className="muted">Run <span className="badge">th deploy</span> and re-publish the manifest for this UI.</div>
<div className="pre">manifest deploymentEntrypointAddress is 0x0</div>
<div className="muted">
This UI reads <span className="badge">/.well-known/tokenhost/manifest.json</span> at runtime, but the manifest still has a placeholder
deployment address (<span className="badge">deploymentEntrypointAddress = 0x0</span>).
</div>
<div className="muted" style={{ marginTop: 12 }}>
Run <span className="badge">th deploy {'<buildDir>'} --chain anvil</span>, then refresh this page.
</div>
<div className="muted" style={{ marginTop: 12 }}>
If you are hosting this UI remotely, publish the updated <span className="badge">manifest.json</span> to{' '}
<span className="badge">/.well-known/tokenhost/manifest.json</span>.
</div>
</div>
);
}
Expand Down Expand Up @@ -300,4 +309,3 @@ export default function EditRecordPage(props: { params: { collection: string } }
</div>
);
}

Loading
Loading