@@ -5,13 +5,30 @@ import path from "node:path"
55
66import { uploadFiles } from "../util/api.js"
77import { getAPIURL , getToken } from "../util/config.js"
8- import { validatePuzzmoJson } from "../util/validatePuzzmoFile .js"
8+ import { discoverGames , type DiscoveredGame } from "../util/discoverGames .js"
99
1010type UploadOptions = {
1111 verbose ?: boolean
1212}
1313
14- /** Uploads game build artifacts to Puzzmo */
14+ type GameSuccess = {
15+ ok : true
16+ slug : string
17+ fileCount : number
18+ totalBytes : number
19+ versionID : string
20+ assetsBase : string
21+ }
22+
23+ type GameFailure = {
24+ ok : false
25+ slug : string
26+ error : string
27+ }
28+
29+ type GameResult = GameSuccess | GameFailure
30+
31+ /** Uploads game build artifacts to Puzzmo by discovering puzzmo.json files in `dir` */
1532export const upload = async ( dir : string , options : UploadOptions = { } ) => {
1633 const { verbose = false } = options
1734 const token = getToken ( )
@@ -20,68 +37,87 @@ export const upload = async (dir: string, options: UploadOptions = {}) => {
2037 process . exit ( 1 )
2138 }
2239
23- const distDir = path . resolve ( dir )
24- if ( ! fs . existsSync ( distDir ) ) {
40+ const rootDir = path . resolve ( dir )
41+ if ( ! fs . existsSync ( rootDir ) ) {
2542 console . error ( `Directory not found: ${ dir } ` )
2643 process . exit ( 1 )
2744 }
2845
29- const files = collectFiles ( distDir )
30- if ( ! files . length ) {
31- console . error ( `Directory is empty: ${ dir } ` )
46+ const { games, errors : discoveryErrors } = await discoverGames ( rootDir )
47+
48+ if ( ! games . length && ! discoveryErrors . length ) {
49+ console . error ( `No puzzmo.json files found under ${ rootDir } ` )
3250 process . exit ( 1 )
3351 }
3452
35- // Require and validate puzzmo.json
36- const puzzmoJsonPath = path . join ( distDir , "puzzmo.json" )
37- if ( ! fs . existsSync ( puzzmoJsonPath ) ) {
38- console . error ( `Missing puzzmo.json in ${ dir } ` )
39- console . error ( "Every game upload must include a puzzmo.json file." )
40- process . exit ( 1 )
53+ console . log ( `Found ${ games . length } game(s) under ${ rootDir } ` )
54+ for ( const game of games ) {
55+ console . log ( ` - ${ game . puzzmoFile . game . slug } (${ path . relative ( rootDir , game . distDir ) || "." } )` )
56+ }
57+ if ( discoveryErrors . length ) {
58+ console . log ( `\n${ discoveryErrors . length } puzzmo.json file(s) could not be loaded:` )
59+ for ( const err of discoveryErrors ) {
60+ console . log ( ` - ${ err . slug ?? path . relative ( rootDir , err . puzzmoJsonPath ) } ` )
61+ for ( const e of err . errors ) console . log ( ` ${ e } ` )
62+ }
4163 }
4264
43- let parsed : unknown
44- try {
45- parsed = JSON . parse ( fs . readFileSync ( puzzmoJsonPath , "utf-8" ) )
46- } catch ( e ) {
47- console . error ( `Invalid puzzmo.json: ${ e instanceof Error ? e . message : e } ` )
48- process . exit ( 1 )
65+ const sha = getGitSHA ( rootDir ) || hashGames ( games )
66+ const description = getGitMessage ( rootDir )
67+ const repoURL = getGitRepoURL ( rootDir )
68+ if ( description ) console . log ( `\nMessage: ${ description } ` )
69+ if ( repoURL ) console . log ( `Repo: ${ repoURL } ` )
70+
71+ const apiURL = getAPIURL ( )
72+ const defaultURL = "https://api.puzzmo.com"
73+ if ( apiURL !== defaultURL ) console . log ( `API: ${ apiURL } ` )
74+
75+ const results : GameResult [ ] = [ ]
76+ for ( const err of discoveryErrors ) {
77+ results . push ( { ok : false , slug : err . slug ?? path . relative ( rootDir , err . puzzmoJsonPath ) , error : err . errors . join ( "; " ) } )
4978 }
5079
51- const validation = await validatePuzzmoJson ( parsed )
52- if ( ! validation . valid ) {
53- console . error ( `Invalid puzzmo.json:\n` )
54- for ( const err of validation . errors ) console . error ( ` ${ err } ` )
55- process . exit ( 1 )
80+ for ( let i = 0 ; i < games . length ; i ++ ) {
81+ const game = games [ i ]
82+ console . log ( `\n[${ i + 1 } /${ games . length } ] Uploading ${ game . puzzmoFile . game . slug } ` )
83+ try {
84+ const result = await uploadOneGame ( game , { token, sha, description, repoURL, verbose, rootDir } )
85+ results . push ( result )
86+ } catch ( e ) {
87+ const message = e instanceof Error ? e . message : String ( e )
88+ console . error ( ` Failed: ${ message } ` )
89+ results . push ( { ok : false , slug : game . puzzmoFile . game . slug , error : message } )
90+ }
5691 }
57- const puzzmoFile = validation . data
58- const gameSlug = puzzmoFile . game . slug
5992
60- // Determine SHA
61- const sha = getGitSHA ( ) || hashFiles ( files )
62- const description = getGitMessage ( )
63- const repoURL = getGitRepoURL ( )
93+ printReport ( results )
6494
65- console . log ( `\nUploading ${ gameSlug } (${ sha . slice ( 0 , 8 ) } )` )
66- console . log ( `Directory: ${ distDir } ` )
67- if ( description ) console . log ( `Message: ${ description } ` )
68- if ( repoURL ) console . log ( `Repo: ${ repoURL } ` )
69- console . log ( "" )
95+ const anyFailed = results . some ( ( r ) => ! r . ok )
96+ if ( anyFailed ) process . exit ( 1 )
97+ }
7098
71- let totalBytes = 0
72- for ( const file of files ) {
73- const size = fs . statSync ( file ) . size
74- totalBytes += size
75- const rel = path . relative ( distDir , file )
76- console . log ( ` ${ rel } (${ formatBytes ( size ) } )` )
77- }
99+ type UploadOneOptions = {
100+ token : string
101+ sha : string
102+ description : string | null
103+ repoURL : string | null
104+ verbose : boolean
105+ rootDir : string
106+ }
78107
79- const apiURL = getAPIURL ( )
80- const defaultURL = "https://api.puzzmo.com"
108+ /** Uploads a single discovered game; throws on failure */
109+ const uploadOneGame = async ( game : DiscoveredGame , opts : UploadOneOptions ) : Promise < GameSuccess > => {
110+ const { token, sha, description, repoURL, verbose, rootDir } = opts
111+ const { puzzmoFile, distDir } = game
112+ const gameSlug = puzzmoFile . game . slug
81113
82- console . log ( `\n${ files . length } file(s), ${ formatBytes ( totalBytes ) } total` )
83- if ( apiURL !== defaultURL ) console . log ( `Uploading to ${ apiURL } ...` )
84- else console . log ( "Uploading..." )
114+ const files = collectFiles ( distDir )
115+ if ( ! files . length ) throw new Error ( `Dist folder is empty: ${ distDir } ` )
116+
117+ console . log ( ` Directory: ${ path . relative ( rootDir , distDir ) || distDir } ` )
118+ let totalBytes = 0
119+ for ( const file of files ) totalBytes += fs . statSync ( file ) . size
120+ console . log ( ` ${ files . length } file(s), ${ formatBytes ( totalBytes ) } total (sha ${ sha . slice ( 0 , 8 ) } )` )
85121
86122 const result = await uploadFiles (
87123 token ,
@@ -91,13 +127,34 @@ export const upload = async (dir: string, options: UploadOptions = {}) => {
91127 distDir ,
92128 puzzmoFile ,
93129 ( batch , totalBatches , uploaded ) => {
94- console . log ( ` Batch ${ batch } /${ totalBatches } done (${ uploaded } file(s) uploaded)` )
130+ console . log ( ` Batch ${ batch } /${ totalBatches } done (${ uploaded } file(s) uploaded)` )
95131 } ,
96132 { verbose, description, repoURL } ,
97133 )
98134
99- console . log ( `\nDone - ${ result . versionID } ` )
100- console . log ( `Assets: ${ result . assetsBase } ` )
135+ console . log ( ` Done - ${ result . versionID } ` )
136+ return {
137+ ok : true ,
138+ slug : gameSlug ,
139+ fileCount : files . length ,
140+ totalBytes,
141+ versionID : result . versionID ,
142+ assetsBase : result . assetsBase ,
143+ }
144+ }
145+
146+ /** Prints a final summary of every game's outcome */
147+ const printReport = ( results : GameResult [ ] ) => {
148+ console . log ( `\nUpload report (${ results . length } game${ results . length === 1 ? "" : "s" } )` )
149+ const successes = results . filter ( ( r ) : r is GameSuccess => r . ok )
150+ const failures = results . filter ( ( r ) : r is GameFailure => ! r . ok )
151+ for ( const r of successes ) {
152+ console . log ( ` OK ${ r . slug . padEnd ( 24 ) } ${ formatBytes ( r . totalBytes ) . padStart ( 8 ) } ${ r . versionID } ` )
153+ }
154+ for ( const r of failures ) {
155+ console . log ( ` FAIL ${ r . slug . padEnd ( 24 ) } ${ r . error } ` )
156+ }
157+ console . log ( `\n${ successes . length } succeeded, ${ failures . length } failed` )
101158}
102159
103160/** Collects all files in a directory recursively */
@@ -113,27 +170,27 @@ const collectFiles = (dir: string): string[] => {
113170}
114171
115172/** Tries to get the shortest unique git SHA, returns null if not in a git repo */
116- const getGitSHA = ( ) : string | null => {
173+ const getGitSHA = ( cwd : string ) : string | null => {
117174 try {
118- return execSync ( "git rev-parse --short HEAD" , { encoding : "utf-8" } ) . trim ( )
175+ return execSync ( "git rev-parse --short HEAD" , { encoding : "utf-8" , cwd } ) . trim ( )
119176 } catch {
120177 return null
121178 }
122179}
123180
124181/** Gets the subject line of the latest commit, or null if not in a git repo */
125- const getGitMessage = ( ) : string | null => {
182+ const getGitMessage = ( cwd : string ) : string | null => {
126183 try {
127- return execSync ( "git log -1 --pretty=%s" , { encoding : "utf-8" } ) . trim ( ) || null
184+ return execSync ( "git log -1 --pretty=%s" , { encoding : "utf-8" , cwd } ) . trim ( ) || null
128185 } catch {
129186 return null
130187 }
131188}
132189
133190/** Gets the origin remote URL normalized to https, or null if unavailable */
134- const getGitRepoURL = ( ) : string | null => {
191+ const getGitRepoURL = ( cwd : string ) : string | null => {
135192 try {
136- const raw = execSync ( "git config --get remote.origin.url" , { encoding : "utf-8" } ) . trim ( )
193+ const raw = execSync ( "git config --get remote.origin.url" , { encoding : "utf-8" , cwd } ) . trim ( )
137194 return raw ? normalizeRepoURL ( raw ) : null
138195 } catch {
139196 return null
@@ -153,11 +210,14 @@ const normalizeRepoURL = (url: string): string => {
153210 return normalized . replace ( / \. g i t $ / , "" )
154211}
155212
156- /** Hashes all file contents to produce a deterministic SHA */
157- const hashFiles = ( filePaths : string [ ] ) : string => {
213+ /** Hashes the dist contents of every game to produce a deterministic SHA across all uploads */
214+ const hashGames = ( games : DiscoveredGame [ ] ) : string => {
158215 const hash = crypto . createHash ( "sha256" )
159- for ( const fp of filePaths . sort ( ) ) {
160- hash . update ( fs . readFileSync ( fp ) )
216+ for ( const game of games ) {
217+ for ( const file of collectFiles ( game . distDir ) . sort ( ) ) {
218+ hash . update ( file )
219+ hash . update ( fs . readFileSync ( file ) )
220+ }
161221 }
162222 return hash . digest ( "hex" ) . slice ( 0 , 12 )
163223}
0 commit comments