Skip to content

Commit 2aa7c47

Browse files
Sync packages from monorepo (68998ed)
1 parent 6c57bca commit 2aa7c47

6 files changed

Lines changed: 359 additions & 101 deletions

File tree

packages/cli/src/commands/upload.ts

Lines changed: 120 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,30 @@ import path from "node:path"
55

66
import { uploadFiles } from "../util/api.js"
77
import { getAPIURL, getToken } from "../util/config.js"
8-
import { validatePuzzmoJson } from "../util/validatePuzzmoFile.js"
8+
import { discoverGames, type DiscoveredGame } from "../util/discoverGames.js"
99

1010
type 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` */
1532
export 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(/\.git$/, "")
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
}
Lines changed: 23 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,39 @@
11
import fs from "node:fs"
22
import path from "node:path"
33

4-
import { validatePuzzmoJson } from "../util/validatePuzzmoFile.js"
4+
import { discoverGames } from "../util/discoverGames.js"
55

6-
/** CLI command: puzzmo validate [dir] */
6+
/** CLI command: puzzmo validate [dir] — discovers every puzzmo.json under dir and validates each */
77
export const validate = async (dir: string) => {
8-
const resolvedDir = path.resolve(dir)
9-
const puzzmoJsonPath = path.join(resolvedDir, "puzzmo.json")
10-
11-
if (!fs.existsSync(puzzmoJsonPath)) {
12-
console.error(`No puzzmo.json found in ${dir}`)
8+
const rootDir = path.resolve(dir)
9+
if (!fs.existsSync(rootDir)) {
10+
console.error(`Directory not found: ${dir}`)
1311
process.exit(1)
1412
}
1513

16-
let raw: string
17-
try {
18-
raw = fs.readFileSync(puzzmoJsonPath, "utf-8")
19-
} catch (e) {
20-
console.error(`Could not read puzzmo.json: ${e instanceof Error ? e.message : e}`)
21-
process.exit(1)
22-
}
14+
const { games, errors } = await discoverGames(rootDir)
2315

24-
let data: unknown
25-
try {
26-
data = JSON.parse(raw)
27-
} catch (e) {
28-
console.error(`Invalid JSON in puzzmo.json: ${e instanceof Error ? e.message : e}`)
16+
if (!games.length && !errors.length) {
17+
console.error(`No puzzmo.json files found under ${rootDir}`)
2918
process.exit(1)
3019
}
3120

32-
const result = await validatePuzzmoJson(data)
33-
if (!result.valid) {
34-
console.error(`puzzmo.json has ${result.errors.length} error(s):\n`)
35-
for (const err of result.errors) console.error(` ${err}`)
36-
process.exit(1)
21+
for (const game of games) {
22+
const rel = path.relative(rootDir, game.puzzmoJsonPath) || game.puzzmoJsonPath
23+
const integrations = game.puzzmoFile.integrations ? Object.keys(game.puzzmoFile.integrations) : []
24+
const distRel = path.relative(rootDir, game.distDir) || game.distDir
25+
console.log(`OK ${game.puzzmoFile.game.slug.padEnd(24)} (${rel})`)
26+
console.log(` dist: ${distRel}`)
27+
if (integrations.length) console.log(` integrations: ${integrations.join(", ")}`)
3728
}
3829

39-
const { data: puzzmoFile } = result
40-
console.log(`Valid puzzmo.json for "${puzzmoFile.game.displayName}" (${puzzmoFile.game.slug})`)
41-
42-
if (puzzmoFile.integrations) {
43-
const keys = Object.keys(puzzmoFile.integrations)
44-
if (keys.length) console.log(`Integrations: ${keys.join(", ")}`)
30+
for (const err of errors) {
31+
const rel = path.relative(rootDir, err.puzzmoJsonPath) || err.puzzmoJsonPath
32+
const label = err.slug ?? rel
33+
console.error(`FAIL ${label.padEnd(24)} (${rel})`)
34+
for (const e of err.errors) console.error(` ${e}`)
4535
}
36+
37+
console.log(`\n${games.length} valid, ${errors.length} invalid`)
38+
if (errors.length) process.exit(1)
4639
}

packages/cli/src/index.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ const printUsage = () => {
1414
puzzmo login <token> Save a CLI auth token
1515
puzzmo game create [token] Create a new Puzzmo game project
1616
17-
puzzmo upload <dir> [-v] Upload game build from <dir> (slug from puzzmo.json). -v/--verbose prints request URLs and full error bodies.
18-
puzzmo validate [dir] Validate puzzmo.json in a directory (default: .)
17+
puzzmo upload [dir] [-v] Discover puzzmo.json files in [dir] (default: .) and upload each game's build. -v/--verbose prints request URLs and full error bodies.
18+
puzzmo validate [dir] Discover and validate every puzzmo.json under [dir] (default: .)
1919
puzzmo migrate List and select migration skills from dev.puzzmo.com`)
2020
}
2121

@@ -32,11 +32,7 @@ const run = async () => {
3232
}
3333
case "upload": {
3434
const verbose = args.includes("--verbose") || args.includes("-v")
35-
const dir = args.find((a) => !a.startsWith("-"))
36-
if (!dir) {
37-
console.error("Usage: puzzmo upload <dir> [-v|--verbose]")
38-
process.exit(1)
39-
}
35+
const dir = args.find((a) => !a.startsWith("-")) ?? "."
4036
await upload(dir, { verbose })
4137
break
4238
}

packages/cli/src/util/api.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ export type PuzzmoFile = {
1010
slug: string
1111
teamID: string
1212
}
13+
// This is what we're calling 'augmentations' publicly
1314
integrations?: Record<string, unknown>
15+
output?: {
16+
dir: string
17+
}
1418
}
1519

1620
const BATCH_SIZE = 10
@@ -60,9 +64,11 @@ const readResponse = async (res: Response, url: string, step: string, verbose: b
6064
throw new Error(`Invalid JSON response during ${step} (${url}): ${verbose ? text : text.slice(0, 200)}`)
6165
}
6266
if (!res.ok) {
63-
const serverMsg = (json as { error?: string }).error || res.statusText || "Unknown error"
67+
const body = json as { error?: string; validationErrors?: string[] }
68+
const serverMsg = body.error || res.statusText || "Unknown error"
69+
const validation = body.validationErrors?.length ? `\n - ${body.validationErrors.join("\n - ")}` : ""
6470
const detail = verbose ? `\n${JSON.stringify(json, null, 2)}` : ""
65-
throw new Error(`Server error during ${step} (${res.status} from ${url}): ${serverMsg}${detail}`)
71+
throw new Error(`Server error during ${step} (${res.status} from ${url}): ${serverMsg}${validation}${detail}`)
6672
}
6773
return json
6874
}

0 commit comments

Comments
 (0)