11import fs from 'fs' ;
2+ import os from 'os' ;
23import path from 'path' ;
34import crypto from 'crypto' ;
45import { spawnSync } from 'child_process' ;
56import { createRequire } from 'module' ;
6- import { fileURLToPath } from 'url' ;
7+ import { fileURLToPath , pathToFileURL } from 'url' ;
78import { Command } from 'commander' ;
89
910import { generateAppSolidity } from '@tokenhost/generator' ;
@@ -112,6 +113,106 @@ function resolveNextExportUiTemplateDir(): string {
112113 ) ;
113114}
114115
116+ function toFileUrl ( p : string ) : string {
117+ return pathToFileURL ( path . resolve ( p ) ) . toString ( ) ;
118+ }
119+
120+ function ensureTrailingSlash ( url : string ) : string {
121+ return url . endsWith ( '/' ) ? url : `${ url } /` ;
122+ }
123+
124+ function runCommand ( cmd : string , args : string [ ] , opts ?: { cwd ?: string } ) {
125+ const res = spawnSync ( cmd , args , {
126+ cwd : opts ?. cwd ,
127+ stdio : 'inherit'
128+ } ) ;
129+ if ( res . error && ( res . error as any ) . code === 'ENOENT' ) {
130+ throw new Error ( `${ cmd } not found on PATH. Install it and retry.` ) ;
131+ }
132+ if ( res . status !== 0 ) {
133+ throw new Error ( `${ cmd } ${ args . join ( ' ' ) } failed with exit code ${ res . status ?? 'unknown' } ` ) ;
134+ }
135+ }
136+
137+ function runPnpmCommand ( args : string [ ] , opts ?: { cwd ?: string } ) {
138+ // Prefer local/global pnpm; fall back to corepack if pnpm isn't installed.
139+ const res = spawnSync ( 'pnpm' , args , { cwd : opts ?. cwd , stdio : 'inherit' } ) ;
140+ if ( res . error && ( res . error as any ) . code === 'ENOENT' ) {
141+ const res2 = spawnSync ( 'corepack' , [ 'pnpm' , ...args ] , { cwd : opts ?. cwd , stdio : 'inherit' } ) ;
142+ if ( res2 . error && ( res2 . error as any ) . code === 'ENOENT' ) {
143+ throw new Error ( `pnpm not found. Install pnpm or enable corepack, then retry.` ) ;
144+ }
145+ if ( res2 . status !== 0 ) {
146+ throw new Error ( `corepack pnpm ${ args . join ( ' ' ) } failed with exit code ${ res2 . status ?? 'unknown' } ` ) ;
147+ }
148+ return ;
149+ }
150+ if ( res . status !== 0 ) {
151+ throw new Error ( `pnpm ${ args . join ( ' ' ) } failed with exit code ${ res . status ?? 'unknown' } ` ) ;
152+ }
153+ }
154+
155+ function renderThsTs ( schema : ThsSchema ) : string {
156+ // Embed the full THS schema in the UI so it can render forms + routes without server-side code.
157+ return (
158+ `/*\n` +
159+ ` * GENERATED FILE\n` +
160+ ` *\n` +
161+ ` * This file is generated by \`th generate\` from the THS schema.\n` +
162+ ` */\n\n` +
163+ `export const ths = ${ JSON . stringify ( schema , null , 2 ) } as const;\n\n` +
164+ `export type Ths = typeof ths;\n`
165+ ) ;
166+ }
167+
168+ function ensureEd25519PrivateKey ( key : crypto . KeyObject ) : crypto . KeyObject {
169+ const type = ( key as any ) . asymmetricKeyType as string | undefined ;
170+ if ( type && type !== 'ed25519' ) {
171+ throw new Error ( `Manifest signing key must be Ed25519 (got ${ type } ).` ) ;
172+ }
173+ return key ;
174+ }
175+
176+ function loadManifestSigningKey ( ) : crypto . KeyObject | null {
177+ const keyPath = process . env . TH_MANIFEST_SIGNING_KEY_PATH ;
178+ if ( keyPath ) {
179+ const pem = fs . readFileSync ( keyPath , 'utf-8' ) ;
180+ return ensureEd25519PrivateKey ( crypto . createPrivateKey ( pem ) ) ;
181+ }
182+
183+ const env = process . env . TH_MANIFEST_SIGNING_KEY || process . env . TH_MANIFEST_SIGNING_PRIVATE_KEY ;
184+ if ( ! env ) return null ;
185+
186+ const raw = env . trim ( ) ;
187+ if ( raw . startsWith ( '-----BEGIN' ) ) {
188+ return ensureEd25519PrivateKey ( crypto . createPrivateKey ( raw ) ) ;
189+ }
190+
191+ // Assume base64-encoded PKCS#8 DER.
192+ const b64 = raw . startsWith ( 'base64:' ) ? raw . slice ( 'base64:' . length ) : raw ;
193+ const der = Buffer . from ( b64 , 'base64' ) ;
194+ return ensureEd25519PrivateKey ( crypto . createPrivateKey ( { key : der , format : 'der' , type : 'pkcs8' } ) ) ;
195+ }
196+
197+ function computeKeyIdEd25519 ( privateKey : crypto . KeyObject ) : string {
198+ const pub = crypto . createPublicKey ( privateKey ) ;
199+ const spki = pub . export ( { format : 'der' , type : 'spki' } ) as Buffer ;
200+ return sha256Digest ( spki ) ;
201+ }
202+
203+ function signManifest ( manifest : any , privateKey : crypto . KeyObject ) : { alg : string ; keyId : string ; sig : string } {
204+ // Sign the canonical manifest digest of the manifest with signatures removed.
205+ // Verifiers should recompute this same digest before verifying.
206+ const unsigned = { ...manifest , signatures : [ ] } ;
207+ const digest = computeSchemaHash ( unsigned ) ;
208+ const signature = crypto . sign ( null , Buffer . from ( digest , 'utf-8' ) , privateKey ) ;
209+ return {
210+ alg : 'ed25519' ,
211+ keyId : computeKeyIdEd25519 ( privateKey ) ,
212+ sig : signature . toString ( 'base64' )
213+ } ;
214+ }
215+
115216function findUp ( filename : string , startDir : string ) : string | null {
116217 let dir = path . resolve ( startDir ) ;
117218 while ( true ) {
@@ -450,17 +551,7 @@ program
450551
451552 const thsTsPath = path . join ( uiDir , 'src' , 'generated' , 'ths.ts' ) ;
452553 ensureDir ( path . dirname ( thsTsPath ) ) ;
453-
454- // Embed the full THS schema in the UI so it can render forms + routes without server-side code.
455- const thsTs =
456- `/*\n` +
457- ` * GENERATED FILE\n` +
458- ` *\n` +
459- ` * This file is generated by \`th generate\` from the THS schema.\n` +
460- ` */\n\n` +
461- `export const ths = ${ JSON . stringify ( schema , null , 2 ) } as const;\n\n` +
462- `export type Ths = typeof ths;\n` ;
463- fs . writeFileSync ( thsTsPath , thsTs ) ;
554+ fs . writeFileSync ( thsTsPath , renderThsTs ( schema ) ) ;
464555
465556 console . log ( `Wrote ui/ (Next.js static export template)` ) ;
466557 }
@@ -472,7 +563,8 @@ program
472563 . command ( 'build' )
473564 . argument ( '<schema>' , 'Path to THS schema JSON file' )
474565 . option ( '--out <dir>' , 'Output directory' , 'artifacts' )
475- . action ( ( schemaPath : string , opts : { out : string } ) => {
566+ . option ( '--no-ui' , 'Do not generate/build UI bundle' )
567+ . action ( ( schemaPath : string , opts : { out : string ; ui : boolean } ) => {
476568 const input = readJsonFile ( schemaPath ) ;
477569 const structural = validateThsStructural ( input ) ;
478570 if ( ! structural . ok ) {
@@ -515,14 +607,69 @@ program
515607 // 3) Write schema copy
516608 fs . writeFileSync ( path . join ( outDir , 'schema.json' ) , JSON . stringify ( schema , null , 2 ) ) ;
517609
518- // 4) Build a local (unsigned) manifest. This is spec-shaped but uses placeholders
519- // for deployments/UI until `th deploy`/`th publish` are implemented.
610+ // 4) Package build artifacts (SPEC 11)
611+ const sourcesTgzPath = path . join ( outDir , 'sources.tgz' ) ;
612+ const compiledTgzPath = path . join ( outDir , 'compiled.tgz' ) ;
613+ runCommand ( 'tar' , [ '-czf' , sourcesTgzPath , '-C' , outDir , path . dirname ( appSol . path ) ] ) ;
614+ runCommand ( 'tar' , [ '-czf' , compiledTgzPath , '-C' , outDir , 'compiled' ] ) ;
615+
616+ // 5) Build UI bundle (Next.js static export) (SPEC 8 / 11)
617+ const emptyUiBundleDigest = computeSchemaHash ( { version : 1 , files : [ ] } ) ;
618+ let uiBundleDigest = emptyUiBundleDigest ;
619+ let uiBaseUrl = ensureTrailingSlash ( process . env . TH_UI_BASE_URL ?? 'http://localhost/' ) ;
620+ let uiBundleDir : string | null = null ;
621+ let uiSiteDir : string | null = null ;
622+
623+ if ( opts . ui ) {
624+ uiBundleDir = path . join ( outDir , 'ui-bundle' ) ;
625+ uiSiteDir = path . join ( outDir , 'ui-site' ) ;
626+ fs . rmSync ( uiBundleDir , { recursive : true , force : true } ) ;
627+ ensureDir ( uiBundleDir ) ;
628+
629+ const uiWorkDir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'tokenhost-ui-build-' ) ) ;
630+ try {
631+ const templateDir = resolveNextExportUiTemplateDir ( ) ;
632+ copyDir ( templateDir , uiWorkDir ) ;
633+
634+ // Inject schema for client-side routing/forms.
635+ const thsTsPath = path . join ( uiWorkDir , 'src' , 'generated' , 'ths.ts' ) ;
636+ ensureDir ( path . dirname ( thsTsPath ) ) ;
637+ fs . writeFileSync ( thsTsPath , renderThsTs ( schema ) ) ;
638+
639+ // Ship ABI alongside the UI so it can operate without additional servers.
640+ const compiledPublicPath = path . join ( uiWorkDir , 'public' , 'compiled' , 'App.json' ) ;
641+ ensureDir ( path . dirname ( compiledPublicPath ) ) ;
642+ fs . writeFileSync ( compiledPublicPath , compiledJson ) ;
643+
644+ // Do not bake a manifest into the UI bundle; it is published separately and signed.
645+ const bakedManifestPath = path . join ( uiWorkDir , 'public' , '.well-known' , 'tokenhost' , 'manifest.json' ) ;
646+ if ( fs . existsSync ( bakedManifestPath ) ) fs . rmSync ( bakedManifestPath , { force : true } ) ;
647+
648+ runPnpmCommand ( [ 'install' ] , { cwd : uiWorkDir } ) ;
649+ runPnpmCommand ( [ 'build' ] , { cwd : uiWorkDir } ) ;
650+
651+ const exportedDir = path . join ( uiWorkDir , 'out' ) ;
652+ if ( ! fs . existsSync ( exportedDir ) ) {
653+ throw new Error ( `UI build did not produce an export directory at ${ exportedDir } .` ) ;
654+ }
655+
656+ // Copy the static export output into the build output directory.
657+ copyDir ( exportedDir , uiBundleDir ) ;
658+ } finally {
659+ fs . rmSync ( uiWorkDir , { recursive : true , force : true } ) ;
660+ }
661+
662+ uiBundleDigest = computeDirectoryDigest ( uiBundleDir ) ;
663+ uiBaseUrl = ensureTrailingSlash ( process . env . TH_UI_BASE_URL ?? toFileUrl ( uiSiteDir ) ) ;
664+ }
665+
666+ // 6) Build a local manifest. This is spec-shaped but uses placeholder deployments
667+ // until `th deploy` updates it.
520668 const schemaHash = computeSchemaHash ( schema ) ;
521669 const sourcesDigest = computeDirectoryDigest ( path . join ( outDir , path . dirname ( appSol . path ) ) ) ;
522670 const compiledDigest = computeDirectoryDigest ( path . join ( outDir , 'compiled' ) ) ;
523671 const abiDigest = sha256Digest ( JSON . stringify ( compiled . abi ) ) ;
524672 const bytecodeDigest = sha256Digest ( compiled . bytecode ) ;
525- const emptyUiBundleDigest = computeSchemaHash ( { version : 1 , files : [ ] } ) ;
526673
527674 const features = {
528675 indexer : schema . app . features ?. indexer ?? false ,
@@ -559,8 +706,8 @@ program
559706 } ,
560707 collections,
561708 artifacts : {
562- soliditySources : { digest : sourcesDigest } ,
563- compiledContracts : { digest : compiledDigest }
709+ soliditySources : { digest : sourcesDigest , url : toFileUrl ( sourcesTgzPath ) } ,
710+ compiledContracts : { digest : compiledDigest , url : toFileUrl ( compiledTgzPath ) }
564711 } ,
565712 deployments : [
566713 {
@@ -585,14 +732,19 @@ program
585732 }
586733 ] ,
587734 ui : {
588- bundleHash : emptyUiBundleDigest ,
589- baseUrl : 'http://localhost/' ,
735+ bundleHash : uiBundleDigest ,
736+ baseUrl : uiBaseUrl ,
590737 wellKnown : '/.well-known/tokenhost/manifest.json'
591738 } ,
592739 features,
593740 signatures : [ { alg : 'none' , sig : 'UNSIGNED' } ]
594741 } ;
595742
743+ const signingKey = loadManifestSigningKey ( ) ;
744+ if ( signingKey ) {
745+ manifest . signatures = [ signManifest ( manifest , signingKey ) ] ;
746+ }
747+
596748 // Validate manifest shape against the local JSON schema.
597749 const { ok, errors : manifestErrors } = validateManifest ( manifest ) ;
598750 if ( ! ok ) {
@@ -603,9 +755,26 @@ program
603755 }
604756
605757 const manifestPath = path . join ( outDir , 'manifest.json' ) ;
606- fs . writeFileSync ( manifestPath , JSON . stringify ( manifest , null , 2 ) ) ;
758+ const manifestJsonOut = JSON . stringify ( manifest , null , 2 ) ;
759+ fs . writeFileSync ( manifestPath , manifestJsonOut ) ;
760+
761+ // Convenience: create a self-hostable static site root that includes the UI bundle + manifest.
762+ // Note: ui.bundleHash is computed over ui-bundle/ (UI code only), not this directory.
763+ if ( uiBundleDir && uiSiteDir ) {
764+ fs . rmSync ( uiSiteDir , { recursive : true , force : true } ) ;
765+ copyDir ( uiBundleDir , uiSiteDir ) ;
766+ ensureDir ( path . join ( uiSiteDir , '.well-known' , 'tokenhost' ) ) ;
767+ fs . writeFileSync ( path . join ( uiSiteDir , '.well-known' , 'tokenhost' , 'manifest.json' ) , manifestJsonOut ) ;
768+ fs . writeFileSync ( path . join ( uiSiteDir , 'manifest.json' ) , manifestJsonOut ) ;
769+ }
607770 console . log ( `Wrote ${ appSol . path } ` ) ;
608771 console . log ( `Wrote compiled/App.json` ) ;
772+ console . log ( `Wrote sources.tgz` ) ;
773+ console . log ( `Wrote compiled.tgz` ) ;
774+ if ( uiBundleDir ) {
775+ console . log ( `Wrote ui-bundle/ (digest: ${ uiBundleDigest } )` ) ;
776+ console . log ( `Wrote ui-site/ (self-hostable static root)` ) ;
777+ }
609778 console . log ( `Wrote manifest.json` ) ;
610779 } ) ;
611780
@@ -730,6 +899,18 @@ program
730899 throw new Error ( 'Schema includes paid creates, but deployed contract has no treasuryAddress constructor.' ) ;
731900 }
732901
902+ // Re-sign manifest after mutating deployments.
903+ const signingKey = loadManifestSigningKey ( ) ;
904+ if ( signingKey ) {
905+ manifest . signatures = [ signManifest ( manifest , signingKey ) ] ;
906+ } else {
907+ const hadRealSig = Array . isArray ( manifest . signatures ) && manifest . signatures . some ( ( s : any ) => s && s . alg && s . alg !== 'none' ) ;
908+ if ( hadRealSig ) {
909+ console . warn ( 'WARN manifest: signing key not provided; clearing signatures and marking UNSIGNED' ) ;
910+ }
911+ manifest . signatures = [ { alg : 'none' , sig : 'UNSIGNED' } ] ;
912+ }
913+
733914 const validation = validateManifest ( manifest ) ;
734915 if ( ! validation . ok ) {
735916 throw new Error ( `Updated manifest failed validation:\n${ JSON . stringify ( validation . errors , null , 2 ) } ` ) ;
0 commit comments