@@ -12,19 +12,28 @@ program
1212 . option ( "-d, --dry-run" , "Simulate the deployment without uploading files" )
1313 . option ( "-y, --yes" , "Skip confirmation prompt" )
1414 . option ( "-f, --force" , "Force upload even if hash matches" )
15+ . option ( "-m, --message <text>" , "Add a note to the deployment notification" )
16+ . option ( "--skip-notification" , "Skip sending Discord notification" )
17+ . option ( "-p, --purge" , "Only purge cache, skip deployment" )
1518 . option ( "-v, --verbose" , "Enable verbose logging" )
1619 . parse ( process . argv ) ;
1720
1821const options = program . opts < {
1922 dryRun : boolean ;
2023 yes : boolean ;
2124 force : boolean ;
25+ message ?: string ;
26+ skipNotification ?: boolean ;
27+ purge ?: boolean ;
2228 verbose : boolean ;
2329} > ( ) ;
2430
2531const STORAGE_ZONE_NAME = process . env . BUNNY_STORAGE_ZONE_NAME ;
2632const ACCESS_KEY = process . env . BUNNY_STORAGE_ACCESS_KEY ;
33+ const API_KEY = process . env . BUNNY_API_KEY ;
34+ const PULL_ZONE_ID = process . env . BUNNY_PULL_ZONE_ID ;
2735const REGION = process . env . BUNNY_STORAGE_REGION || "" ;
36+ const DISCORD_WEBHOOK_URL = process . env . DISCORD_WEBHOOK_URL ;
2837const PUBLIC_CDN_URL = "https://databuddy.b-cdn.net" ;
2938
3039if ( ! STORAGE_ZONE_NAME ) {
@@ -61,37 +70,52 @@ async function fetchRemoteHash(filename: string): Promise<string | null> {
6170 }
6271}
6372
64- async function uploadFile ( filename : string ) {
73+ async function checkFileStatus ( filename : string ) : Promise < {
74+ filename : string ;
75+ status : "changed" | "same" | "new" | "error" ;
76+ size : number ;
77+ content ?: string ;
78+ } > {
6579 const filePath = join ( DIST_DIR , filename ) ;
6680 const fileContent = file ( filePath ) ;
6781
6882 if ( ! ( await fileContent . exists ( ) ) ) {
69- console . warn ( chalk . yellow ( `⚠️ File not found: ${ filename } ` ) ) ;
70- return ;
83+ return { filename, status : "error" , size : 0 } ;
7184 }
7285
7386 const content = await fileContent . text ( ) ;
7487 const localHash = getHash ( content ) ;
7588 const remoteHash = await fetchRemoteHash ( filename ) ;
89+ const size = ( await fileContent . size ) / 1024 ; // KB
7690
77- if ( remoteHash === localHash && ! options . force ) {
78- if ( options . verbose ) {
79- console . log ( chalk . gray ( `⏭️ Skipping ${ filename } (hash match)` ) ) ;
80- } else {
81- console . log ( chalk . gray ( `⏭️ ${ filename } ` ) ) ;
82- }
83- return ;
91+ if ( ! remoteHash ) {
92+ return { filename, status : "new" , size, content } ;
93+ }
94+
95+ if ( remoteHash !== localHash || options . force ) {
96+ return { filename, status : "changed" , size, content } ;
8497 }
8598
99+ return { filename, status : "same" , size } ;
100+ }
101+
102+ async function uploadFile (
103+ filename : string ,
104+ content : string ,
105+ size : number
106+ ) : Promise < {
107+ filename : string ;
108+ status : "uploaded" | "dry-run" | "error" ;
109+ size : number ;
110+ } > {
86111 const url = `${ BASE_URL } /${ STORAGE_ZONE_NAME } /${ filename } ` ;
87- const size = ( await fileContent . size ) / 1024 ; // KB
88112
89113 if ( options . dryRun ) {
90114 console . log (
91115 chalk . cyan ( `[DRY RUN] Would upload ${ chalk . bold ( filename ) } ` ) +
92116 chalk . dim ( ` (${ size . toFixed ( 2 ) } KB) to ${ url } ` )
93117 ) ;
94- return ;
118+ return { filename , status : "dry-run" , size } ;
95119 }
96120
97121 if ( options . verbose ) {
@@ -106,7 +130,7 @@ async function uploadFile(filename: string) {
106130 AccessKey : ACCESS_KEY as string ,
107131 "Content-Type" : "application/javascript" ,
108132 } ,
109- body : content , // Use text content to match hash calculation
133+ body : content ,
110134 } ) ;
111135
112136 if ( ! response . ok ) {
@@ -118,9 +142,97 @@ async function uploadFile(filename: string) {
118142 console . log (
119143 chalk . green ( `✅ Uploaded ${ filename } ` ) + chalk . dim ( ` in ${ duration } ms` )
120144 ) ;
145+ return { filename, status : "uploaded" , size } ;
121146 } catch ( error ) {
122147 console . error ( chalk . red ( `❌ Failed to upload ${ filename } :` ) , error ) ;
123- process . exit ( 1 ) ;
148+ return { filename, status : "error" , size } ;
149+ }
150+ }
151+
152+ async function sendDiscordNotification (
153+ uploadedFiles : { filename : string ; size : number } [ ] ,
154+ message ?: string
155+ ) {
156+ if ( ! DISCORD_WEBHOOK_URL ) {
157+ return ;
158+ }
159+
160+ try {
161+ const totalSize = uploadedFiles . reduce ( ( acc , f ) => acc + f . size , 0 ) ;
162+ const fileList = uploadedFiles
163+ . map ( ( f ) => `- **${ f . filename } ** (${ f . size . toFixed ( 2 ) } KB)` )
164+ . join ( "\n" ) ;
165+
166+ const embed = {
167+ title : "Tracker Scripts Deployed" ,
168+ description : message
169+ ? `> ${ message } `
170+ : "A new version of the tracker scripts has been deployed to the CDN." ,
171+ color : 5_763_719 , // Green
172+ fields : [
173+ {
174+ name : "Updated Files" ,
175+ value : fileList ,
176+ inline : false ,
177+ } ,
178+ {
179+ name : "Deployment Stats" ,
180+ value : `**Total Size:** ${ totalSize . toFixed ( 2 ) } KB\n**Files:** ${ uploadedFiles . length } \n**Environment:** Production` ,
181+ inline : false ,
182+ } ,
183+ ] ,
184+ timestamp : new Date ( ) . toISOString ( ) ,
185+ footer : {
186+ text : "Databuddy Tracker Deployment" ,
187+ } ,
188+ } ;
189+
190+ await fetch ( DISCORD_WEBHOOK_URL , {
191+ method : "POST" ,
192+ headers : { "Content-Type" : "application/json" } ,
193+ body : JSON . stringify ( { embeds : [ embed ] } ) ,
194+ } ) ;
195+ console . log ( chalk . blue ( "\n📨 Discord notification sent" ) ) ;
196+ } catch ( error ) {
197+ console . error (
198+ chalk . yellow ( "⚠️ Failed to send Discord notification:" ) ,
199+ error
200+ ) ;
201+ }
202+ }
203+
204+ async function purgePullZoneCache ( ) {
205+ if ( ! ( API_KEY && PULL_ZONE_ID ) ) {
206+ console . warn (
207+ chalk . yellow (
208+ "⚠️ Missing BUNNY_API_KEY or BUNNY_PULL_ZONE_ID. Skipping cache purge."
209+ )
210+ ) ;
211+ return ;
212+ }
213+
214+ try {
215+ const url = `https://api.bunny.net/pullzone/${ PULL_ZONE_ID } /purgeCache` ;
216+ const response = await fetch ( url , {
217+ method : "POST" ,
218+ headers : {
219+ AccessKey : API_KEY ,
220+ "Content-Type" : "application/json" ,
221+ } ,
222+ } ) ;
223+
224+ if ( response . status === 204 || response . ok ) {
225+ console . log ( chalk . green ( "🧹 Successfully purged Pull Zone cache" ) ) ;
226+ } else {
227+ const text = await response . text ( ) ;
228+ console . error (
229+ chalk . red (
230+ `❌ Failed to purge Pull Zone cache: ${ response . status } - ${ text } `
231+ )
232+ ) ;
233+ }
234+ } catch ( error ) {
235+ console . error ( chalk . red ( "❌ Failed to purge Pull Zone cache:" ) , error ) ;
124236 }
125237}
126238
@@ -147,25 +259,43 @@ async function deploy() {
147259 console . log ( chalk . dim ( `Files: ${ jsFiles . join ( ", " ) } ` ) ) ;
148260 }
149261
150- // Only prompt if not skipping checks and there are actual changes to deploy
151- // But we need to check hashes first to know if there are changes.
152- // For simplicity, we'll iterate files, check hash, and upload/skip.
153- // The prompt is "Are you sure you want to deploy these files?" implies ALL files.
154- // Let's keep the prompt before starting the process.
262+ // Check file statuses first
263+ console . log ( chalk . dim ( "Checking for changes..." ) ) ;
264+ const fileStatuses = await Promise . all ( jsFiles . map ( checkFileStatus ) ) ;
265+
266+ const changedFiles = fileStatuses . filter (
267+ ( f ) => f . status === "changed" || f . status === "new"
268+ ) ;
269+
270+ if ( changedFiles . length === 0 ) {
271+ console . log (
272+ chalk . green ( "✨ No changes detected. Everything is up to date." )
273+ ) ;
274+ return ;
275+ }
276+
277+ console . log (
278+ chalk . bold (
279+ `\n📦 Found ${ changedFiles . length } files to update in ${ chalk . cyan ( STORAGE_ZONE_NAME ) } :`
280+ )
281+ ) ;
282+
283+ for ( const file of changedFiles ) {
284+ const icon = file . status === "new" ? "🆕" : "🔄" ;
285+ console . log (
286+ ` ${ icon } ${ chalk . white ( file . filename ) } ${ chalk . dim (
287+ `(${ file . size . toFixed ( 2 ) } KB)`
288+ ) } `
289+ ) ;
290+ }
155291
156292 const skipConfirmation = options . yes || options . dryRun ;
157293
158294 if ( ! skipConfirmation ) {
159- // Ideally we would pre-calculate what NEEDS uploading, but that requires fetching all remote hashes first.
160- // Let's do a quick check or just prompt generally.
161- // Given the request is just "skip uploading if hash matches", we can do it per-file.
162- // But user might want to know WHAT will be uploaded before confirming.
163- // For now, let's keep the simple flow: Prompt -> Iterate & Check/Upload.
164-
165295 const { confirm } = await import ( "@inquirer/prompts" ) ;
166296 const answer = await confirm ( {
167- message : "Are you sure you want to start the deployment process ?" ,
168- default : false ,
297+ message : "Do you want to proceed with the deployment?" ,
298+ default : true ,
169299 } ) ;
170300
171301 if ( ! answer ) {
@@ -174,7 +304,20 @@ async function deploy() {
174304 }
175305 }
176306
177- await Promise . all ( jsFiles . map ( uploadFile ) ) ;
307+ const uploadPromises = changedFiles . map ( ( f ) => {
308+ if ( ! f . content ) {
309+ // Should not happen given checkFileStatus logic for changed/new
310+ return Promise . resolve ( {
311+ filename : f . filename ,
312+ status : "error" as const ,
313+ size : 0 ,
314+ } ) ;
315+ }
316+ return uploadFile ( f . filename , f . content , f . size ) ;
317+ } ) ;
318+
319+ const results = await Promise . all ( uploadPromises ) ;
320+ const uploaded = results . filter ( ( r ) => r . status === "uploaded" ) ;
178321
179322 if ( options . dryRun ) {
180323 console . log (
@@ -183,14 +326,37 @@ async function deploy() {
183326 } else {
184327 console . log (
185328 chalk . green (
186- `\n✨ Deployment process completed! (${ jsFiles . length } files processed )`
329+ `\n✨ Deployment process completed! (${ uploaded . length } files updated )`
187330 )
188331 ) ;
332+
333+ if ( uploaded . length > 0 ) {
334+ console . log ( chalk . dim ( "\n🧹 Purging Pull Zone cache..." ) ) ;
335+ await purgePullZoneCache ( ) ;
336+
337+ if ( options . skipNotification ) {
338+ console . log (
339+ chalk . gray ( "🔕 Skipping Discord notification (--skip-notification)" )
340+ ) ;
341+ } else {
342+ await sendDiscordNotification ( uploaded , options . message ) ;
343+ }
344+ }
189345 }
190346 } catch ( error ) {
191347 console . error ( chalk . red ( "❌ Deployment failed:" ) , error ) ;
192348 process . exit ( 1 ) ;
193349 }
194350}
195351
196- deploy ( ) ;
352+ if ( options . purge ) {
353+ console . log ( chalk . bold ( "\n🧹 Purging Pull Zone cache..." ) ) ;
354+ purgePullZoneCache ( ) . then ( ( ) => {
355+ process . exit ( 0 ) ;
356+ } ) . catch ( ( error ) => {
357+ console . error ( chalk . red ( "❌ Purge failed:" ) , error ) ;
358+ process . exit ( 1 ) ;
359+ } ) ;
360+ } else {
361+ deploy ( ) ;
362+ }
0 commit comments