11#!/usr/bin/env bun
22
33import { $ } from "bun" ;
4+ import { createHash } from "node:crypto" ;
5+ import { readFile , stat } from "node:fs/promises" ;
46import { join , resolve } from "node:path" ;
57
68const root = resolve ( import . meta. dirname , ".." ) ;
79const extensionsDir = join ( root , "extensions" ) ;
810const packagesDir = join ( root , "packages" ) ;
11+ const databaseDir = join ( root , "database" ) ;
912const targetDir = process . env . EXTENSIONS_CDN_ROOT ;
13+ const cdnBaseUrl = process . env . EXTENSIONS_CDN_BASE_URL || "https://athas.dev/extensions" ;
1014
1115if ( ! targetDir ) {
1216 console . error ( "Missing EXTENSIONS_CDN_ROOT environment variable." ) ;
@@ -25,9 +29,98 @@ await $`rsync -az --include='*/' --include='*.json' --include='*.scm' --include=
2529// Sync packaged installable extensions
2630await $ `rsync -az ${ packagesDir } / ${ targetDir } /packages/` ;
2731
32+ // Sync packaged database sidecars
33+ await $ `test ! -d ${ databaseDir } || rsync -az ${ databaseDir } / ${ targetDir } /database/` ;
34+
2835// Sync root-level registry files
2936for ( const file of [ "registry.json" , "index.json" , "manifests.json" ] ) {
3037 await $ `cp ${ join ( root , file ) } ${ targetDir } /${ file } ` ;
3138}
3239
40+ type InstallablePackage = {
41+ url : string ;
42+ size : number ;
43+ checksum : string ;
44+ } ;
45+
46+ function collectInstallablePackages ( value : unknown , packages : InstallablePackage [ ] = [ ] ) {
47+ if ( Array . isArray ( value ) ) {
48+ for ( const item of value ) {
49+ collectInstallablePackages ( item , packages ) ;
50+ }
51+ return packages ;
52+ }
53+
54+ if ( ! value || typeof value !== "object" ) {
55+ return packages ;
56+ }
57+
58+ const entry = value as Record < string , unknown > ;
59+ if (
60+ typeof entry . downloadUrl === "string" &&
61+ typeof entry . size === "number" &&
62+ entry . size > 0 &&
63+ typeof entry . checksum === "string" &&
64+ entry . checksum . length > 0
65+ ) {
66+ packages . push ( {
67+ url : entry . downloadUrl ,
68+ size : entry . size ,
69+ checksum : entry . checksum ,
70+ } ) ;
71+ }
72+
73+ for ( const item of Object . values ( entry ) ) {
74+ collectInstallablePackages ( item , packages ) ;
75+ }
76+
77+ return packages ;
78+ }
79+
80+ async function sha256 ( path : string ) {
81+ const bytes = await readFile ( path ) ;
82+ return createHash ( "sha256" ) . update ( bytes ) . digest ( "hex" ) ;
83+ }
84+
85+ async function verifyInstallablePackages ( ) {
86+ const manifests = JSON . parse ( await readFile ( join ( root , "manifests.json" ) , "utf8" ) ) as unknown ;
87+ const cdnPrefix = `${ cdnBaseUrl . replace ( / \/ $ / , "" ) } /` ;
88+ const failures : string [ ] = [ ] ;
89+ const installablePackages = new Map (
90+ collectInstallablePackages ( manifests ) . map ( ( installablePackage ) => [
91+ installablePackage . url ,
92+ installablePackage ,
93+ ] ) ,
94+ ) ;
95+
96+ for ( const installablePackage of installablePackages . values ( ) ) {
97+ if ( ! installablePackage . url . startsWith ( cdnPrefix ) ) {
98+ continue ;
99+ }
100+
101+ const relativePath = installablePackage . url . slice ( cdnPrefix . length ) ;
102+ const deployedPath = join ( targetDir ! , relativePath ) ;
103+
104+ try {
105+ const fileStats = await stat ( deployedPath ) ;
106+ const checksum = await sha256 ( deployedPath ) ;
107+ if ( fileStats . size !== installablePackage . size || checksum !== installablePackage . checksum ) {
108+ failures . push (
109+ `${ relativePath } : expected ${ installablePackage . size } /${ installablePackage . checksum } , got ${ fileStats . size } /${ checksum } ` ,
110+ ) ;
111+ }
112+ } catch ( error ) {
113+ failures . push (
114+ `${ relativePath } : ${ error instanceof Error ? error . message : String ( error ) } ` ,
115+ ) ;
116+ }
117+ }
118+
119+ if ( failures . length > 0 ) {
120+ throw new Error ( `Extension CDN verification failed:\n${ failures . join ( "\n" ) } ` ) ;
121+ }
122+ }
123+
124+ await verifyInstallablePackages ( ) ;
125+
33126console . log ( "Extensions CDN sync complete." ) ;
0 commit comments