@puzzmo/cli 1.0.37 → 1.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/lib/commands/game/create.js +187 -71
  2. package/lib/commands/game/create.js.map +1 -1
  3. package/lib/commands/upload.d.ts +1 -1
  4. package/lib/commands/upload.js +143 -64
  5. package/lib/commands/upload.js.map +1 -1
  6. package/lib/commands/validate.d.ts +1 -1
  7. package/lib/commands/validate.js +24 -31
  8. package/lib/commands/validate.js.map +1 -1
  9. package/lib/index.js +4 -8
  10. package/lib/index.js.map +1 -1
  11. package/lib/skills/runner.d.ts +2 -0
  12. package/lib/skills/runner.js +33 -0
  13. package/lib/skills/runner.js.map +1 -1
  14. package/lib/util/api.d.ts +8 -0
  15. package/lib/util/api.js +22 -3
  16. package/lib/util/api.js.map +1 -1
  17. package/lib/util/createGame.d.ts +13 -0
  18. package/lib/util/createGame.js +36 -0
  19. package/lib/util/createGame.js.map +1 -0
  20. package/lib/util/discoverGames.d.ts +25 -0
  21. package/lib/util/discoverGames.js +181 -0
  22. package/lib/util/discoverGames.js.map +1 -0
  23. package/lib/util/validatePuzzmoFile.d.ts +1 -1
  24. package/lib/util/validatePuzzmoFile.js +2 -2
  25. package/lib/util/validatePuzzmoFile.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/commands/game/create.ts +186 -74
  28. package/src/commands/upload.ts +171 -61
  29. package/src/commands/validate.ts +23 -30
  30. package/src/index.ts +6 -8
  31. package/src/skills/runner.ts +35 -0
  32. package/src/util/api.ts +32 -9
  33. package/src/util/createGame.ts +60 -0
  34. package/src/util/discoverGames.ts +203 -0
  35. package/src/util/validatePuzzmoFile.ts +2 -2
  36. package/templates/minesweeper/README.md +13 -0
  37. package/templates/minesweeper/fixtures/puzzles/easy/9x9.json +16 -0
  38. package/templates/minesweeper/fixtures/puzzles/hard/16x16.json +46 -0
  39. package/templates/minesweeper/index.html +19 -0
  40. package/templates/minesweeper/package.json +17 -0
  41. package/templates/minesweeper/puzzmo.json +11 -0
  42. package/templates/minesweeper/src/appBundle.ts +122 -0
  43. package/templates/minesweeper/src/main.ts +236 -0
  44. package/templates/minesweeper/src/style.css +160 -0
  45. package/templates/minesweeper/tsconfig.json +14 -0
  46. package/templates/minesweeper/vite.config.ts +10 -0
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs"
2
2
  import path from "node:path"
3
+ import { fileURLToPath } from "node:url"
3
4
  import { createRequire } from "node:module"
4
5
  import * as p from "@clack/prompts"
5
6
 
@@ -7,17 +8,21 @@ import { agentNames } from "../../agents/index.js"
7
8
  import { detectAgent } from "../../wizard/agent-detect.js"
8
9
  import { downloadPage } from "../../download/page-downloader.js"
9
10
  import { runCommand, gitCommit } from "../../util/exec.js"
10
- import { runSkillsPipelineTUI } from "../../skills/runner.js"
11
+ import { runSkillsPipelineTUI, runAgentWithBuildLoop } from "../../skills/runner.js"
11
12
  import { login } from "../login.js"
12
13
  import { getToken } from "../../util/config.js"
13
14
  import { detectRepoContext, type RepoType } from "./detectRepo.js"
14
15
 
16
+ type Strategy = "import" | "blank" | "prompt"
17
+
15
18
  type CreateOptions = {
16
19
  name?: string
17
20
  url?: string
18
21
  agent?: string
19
22
  accessToken?: string
20
23
  pm?: string
24
+ strategy?: Strategy
25
+ prompt?: string
21
26
  }
22
27
 
23
28
  /** Parses CLI args into CreateOptions */
@@ -27,16 +32,15 @@ const parseArgs = (args: string[]): CreateOptions => {
27
32
 
28
33
  while (i < args.length) {
29
34
  const arg = args[i]
30
- if (arg === "--name" && args[i + 1]) {
31
- opts.name = args[++i]
32
- } else if (arg === "--url" && args[i + 1]) {
33
- opts.url = args[++i]
34
- } else if (arg === "--agent" && args[i + 1]) {
35
- opts.agent = args[++i]
36
- } else if (arg === "--token" && args[i + 1]) {
37
- opts.accessToken = args[++i]
38
- } else if (arg === "--pm" && args[i + 1]) {
39
- opts.pm = args[++i]
35
+ if (arg === "--name" && args[i + 1]) opts.name = args[++i]
36
+ else if (arg === "--url" && args[i + 1]) opts.url = args[++i]
37
+ else if (arg === "--agent" && args[i + 1]) opts.agent = args[++i]
38
+ else if (arg === "--token" && args[i + 1]) opts.accessToken = args[++i]
39
+ else if (arg === "--pm" && args[i + 1]) opts.pm = args[++i]
40
+ else if (arg === "--prompt" && args[i + 1]) opts.prompt = args[++i]
41
+ else if (arg === "--strategy" && args[i + 1]) {
42
+ const v = args[++i]
43
+ if (v === "import" || v === "blank" || v === "prompt") opts.strategy = v
40
44
  }
41
45
  i++
42
46
  }
@@ -49,7 +53,7 @@ const slugify = (text: string) =>
49
53
  text
50
54
  .toString()
51
55
  .normalize("NFD")
52
- .replace(/[\u0300-\u036f]/g, "")
56
+ .replace(/[̀-ͯ]/g, "")
53
57
  .toLowerCase()
54
58
  .trim()
55
59
  .replace(/\s+/g, "-")
@@ -157,6 +161,71 @@ const convertToMultiGame = (repoRoot: string): string => {
157
161
  return existingName
158
162
  }
159
163
 
164
+ /** Resolves the bundled template directory by name. Templates ship at packages/cli/templates/<name>/ */
165
+ const resolveTemplateDir = (name: string): string => {
166
+ const here = fileURLToPath(import.meta.url)
167
+ // here = .../packages/cli/lib/commands/game/create.js → ../../../templates/<name>
168
+ return path.resolve(here, "..", "..", "..", "..", "templates", name)
169
+ }
170
+
171
+ /** File extensions where we apply template variable substitution */
172
+ const textExtension = new Set([".md", ".json", ".html", ".css", ".ts", ".tsx", ".js", ".jsx", ".mjs"])
173
+
174
+ /** Recursively copies a template directory, substituting placeholders in text files. */
175
+ const copyTemplate = (sourceDir: string, targetDir: string, replacements: Record<string, string>) => {
176
+ fs.mkdirSync(targetDir, { recursive: true })
177
+ for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
178
+ const src = path.join(sourceDir, entry.name)
179
+ const dst = path.join(targetDir, entry.name)
180
+ if (entry.isDirectory()) {
181
+ copyTemplate(src, dst, replacements)
182
+ } else if (entry.isFile()) {
183
+ const ext = path.extname(entry.name)
184
+ if (textExtension.has(ext) || entry.name === ".gitignore") {
185
+ let content = fs.readFileSync(src, "utf-8")
186
+ for (const [key, value] of Object.entries(replacements)) content = content.replaceAll(key, value)
187
+ fs.writeFileSync(dst, content)
188
+ } else {
189
+ fs.copyFileSync(src, dst)
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ /** Picks an agent interactively, returning the agent id or "none" */
196
+ const pickAgent = async (preselected?: string): Promise<string> => {
197
+ const supported = new Set(agentNames())
198
+ const agents = detectAgent().filter((a) => supported.has(a.id))
199
+ if (preselected) return preselected
200
+ if (agents.length === 0) {
201
+ p.log.info(`No supported LLM agents detected (checked: ${agentNames().join(", ")})`)
202
+ return "none"
203
+ }
204
+ const choices = [
205
+ ...agents.map((a) => ({ value: a.id, label: `${a.displayName} (${a.path})` })),
206
+ { value: "none", label: "None - I'll run the steps manually" },
207
+ ]
208
+ const result = (await p.select({ message: "Which LLM agent do you use?", options: choices })) as string
209
+ if (p.isCancel(result)) process.exit(0)
210
+ return result
211
+ }
212
+
213
+ /** Composes the prompt sent to the agent for the "from prompt" strategy */
214
+ const buildPromptStrategyMessage = (userPrompt: string, displayName: string): string =>
215
+ [
216
+ `You are scaffolding a Puzzmo game called "${displayName}" in the current directory.`,
217
+ `The starter is a working Vite + Puzzmo SDK Minesweeper. Replace the gameplay with the game described below.`,
218
+ ``,
219
+ `Game description:`,
220
+ userPrompt,
221
+ ``,
222
+ `Constraints:`,
223
+ `- Keep puzzmo.json valid (do not remove the slug or displayName fields).`,
224
+ `- Use the Puzzmo SDK lifecycle: gameReady → gameLoaded → on("start"|"retry") → updateGameState → gameCompleted.`,
225
+ `- Replace fixtures in fixtures/puzzles/ with puzzles for the new game.`,
226
+ `- Keep the build green (\`npx vite build\` should succeed).`,
227
+ ].join("\n")
228
+
160
229
  /** Main game create wizard */
161
230
  export const gameCreate = async (args: string[]) => {
162
231
  const opts = parseArgs(args)
@@ -165,56 +234,105 @@ export const gameCreate = async (args: string[]) => {
165
234
  const { version } = require("../../../package.json")
166
235
  p.intro(`Puzzmo Game Creator v${version}`)
167
236
 
168
- // Step 1: Detect repo context and choose mode
237
+ // Step 1: Pick strategy
238
+ let strategy: Strategy
239
+ if (opts.strategy) {
240
+ strategy = opts.strategy
241
+ } else {
242
+ const choice = await p.select({
243
+ message: "How would you like to create your game?",
244
+ options: [
245
+ { value: "blank" as const, label: "Blank game (Minesweeper template)" },
246
+ { value: "import" as const, label: "Import from an existing URL (uses an LLM to migrate)" },
247
+ { value: "prompt" as const, label: "From a prompt (Builds on template + LLM customizations)" },
248
+ ],
249
+ })
250
+ if (p.isCancel(choice)) process.exit(0)
251
+ strategy = choice as Strategy
252
+ }
253
+
254
+ // Step 2: Detect repo and pick placement
169
255
  const repo = detectRepoContext()
170
256
  const canAddToRepo = repo.repoType === "multi-game" || repo.repoType === "workspace-monorepo" || repo.repoType === "standalone"
171
257
 
172
- const modeOptions = [
258
+ const placementOptions = [
173
259
  { value: "new-repo" as const, label: "Create game in a new repo" },
174
260
  ...(canAddToRepo ? [{ value: "add-to-repo" as const, label: "Add a game to this repo" }] : []),
175
261
  ]
176
- if (canAddToRepo && repo.repoType !== "none") modeOptions.reverse()
262
+ if (canAddToRepo && repo.repoType !== "none") placementOptions.reverse()
177
263
 
178
- const mode = await p.select({
179
- message: "How would you like to create your game?",
180
- options: modeOptions,
181
- })
182
-
183
- if (p.isCancel(mode)) process.exit(0)
184
-
185
- // Step 2: Source URL
186
- let url = opts.url
187
- if (!url) {
188
- url = (await p.text({ message: "Source URL to import", validate: (v) => (!v ? "URL is required" : undefined) })) as string
189
- if (p.isCancel(url)) process.exit(0)
264
+ let mode: "new-repo" | "add-to-repo"
265
+ if (placementOptions.length === 1) {
266
+ mode = placementOptions[0].value
267
+ } else {
268
+ const selected = await p.select({ message: "Where should the game live?", options: placementOptions })
269
+ if (p.isCancel(selected)) process.exit(0)
270
+ mode = selected as "new-repo" | "add-to-repo"
190
271
  }
191
272
 
192
- // Step 3: Download page to a temp dir, extract title
273
+ // Step 3: Acquire source content into tmpDir
193
274
  const tmpDir = path.resolve(".puzzmo-import-tmp")
194
275
  if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true })
195
- const s = p.spinner()
196
- s.start(`Downloading ${url}`)
197
- const { title } = await downloadPage(url, tmpDir)
198
- s.stop("Download complete.")
276
+
277
+ let importedTitle: string | undefined
278
+ let userPrompt: string | undefined
279
+
280
+ if (strategy === "import") {
281
+ let url = opts.url
282
+ if (!url) {
283
+ url = (await p.text({ message: "Source URL to import", validate: (v) => (!v ? "URL is required" : undefined) })) as string
284
+ if (p.isCancel(url)) process.exit(0)
285
+ }
286
+ const s = p.spinner()
287
+ s.start(`Downloading ${url}`)
288
+ const { title } = await downloadPage(url, tmpDir)
289
+ s.stop("Download complete.")
290
+ importedTitle = title
291
+ } else if (strategy === "prompt") {
292
+ userPrompt = opts.prompt
293
+ if (!userPrompt) {
294
+ userPrompt = (await p.text({
295
+ message: "Describe the game you want to build",
296
+ placeholder: "A word search where letters appear in waves...",
297
+ validate: (v) => (!v ? "Prompt is required" : undefined),
298
+ })) as string
299
+ if (p.isCancel(userPrompt)) process.exit(0)
300
+ }
301
+ }
199
302
 
200
303
  // Step 4: Game name
201
- const defaultName = opts.name || title
202
- const name = (await p.text({
203
- message: "Game name",
204
- initialValue: defaultName,
205
- validate: (v) => (!v ? "Game name is required" : undefined),
206
- })) as string
207
- if (p.isCancel(name)) process.exit(0)
304
+ let name: string
305
+ if (opts.name) {
306
+ name = opts.name
307
+ } else {
308
+ const result = (await p.text({
309
+ message: "Game name",
310
+ initialValue: importedTitle || "My Game",
311
+ validate: (v) => (!v ? "Game name is required" : undefined),
312
+ })) as string
313
+ if (p.isCancel(result)) process.exit(0)
314
+ name = result
315
+ }
208
316
 
209
317
  const slug = slugify(name)
210
318
 
319
+ // Materialize template content for blank/prompt strategies (after we know the slug/name)
320
+ if (strategy !== "import") {
321
+ const templateDir = resolveTemplateDir("minesweeper")
322
+ if (!fs.existsSync(templateDir)) {
323
+ p.log.error(`Bundled template not found at ${templateDir}`)
324
+ process.exit(1)
325
+ }
326
+ copyTemplate(templateDir, tmpDir, { __SLUG__: slug, __DISPLAY_NAME__: name })
327
+ }
328
+
211
329
  // Step 5: Login if token provided
212
330
  if (opts.accessToken) {
213
331
  p.log.step("Logging in...")
214
332
  login(opts.accessToken)
215
333
  }
216
334
 
217
- // Step 6: Diamond — set up repo or add to existing
335
+ // Step 6: Place files
218
336
  let gameDir: string
219
337
  let repoType: RepoType = repo.repoType
220
338
 
@@ -230,14 +348,12 @@ export const gameCreate = async (args: string[]) => {
230
348
  process.exit(1)
231
349
  }
232
350
 
233
- // Standalone repo → convert to multi-game first
234
351
  if (repo.repoType === "standalone") {
235
352
  const existingGame = convertToMultiGame(repo.repoRoot)
236
353
  p.log.step(`Moved existing game to games/${existingGame}/`)
237
354
  repoType = "multi-game"
238
355
  }
239
356
 
240
- // Determine which folder to place the game in
241
357
  let parentFolder: string
242
358
  if (repoType === "multi-game") {
243
359
  parentFolder = "games"
@@ -261,38 +377,34 @@ export const gameCreate = async (args: string[]) => {
261
377
  gameDir = setupRepoGame(tmpDir, slug, repo.repoRoot, parentFolder)
262
378
  }
263
379
 
264
- // Step 7: Detect agent and run skills pipeline. Only offer CLIs we have an adapter for.
265
- const supported = new Set(agentNames())
266
- const agents = detectAgent().filter((a) => supported.has(a.id))
267
- const agentChoices = [
268
- ...agents.map((a) => ({ value: a.id, label: `${a.displayName} (${a.path})` })),
269
- { value: "none", label: "None - I'll run the steps manually" },
270
- ]
271
-
272
- let selectedAgent: string
273
- if (opts.agent) {
274
- selectedAgent = opts.agent
275
- } else if (agents.length > 0) {
276
- selectedAgent = (await p.select({ message: "Which LLM agent do you use?", options: agentChoices })) as string
277
- if (p.isCancel(selectedAgent)) process.exit(0)
278
- } else {
279
- p.log.info(`No supported LLM agents detected (checked: ${agentNames().join(", ")})`)
280
- selectedAgent = "none"
281
- }
282
-
283
- if (selectedAgent !== "none") {
284
- const repoContextLines = [
285
- `Repo type: ${repoType}`,
286
- `Package manager: ${repo.packageManager} (use this instead of npm/npx when running commands or adding dependencies)`,
287
- ]
288
- if (repoType === "multi-game")
289
- repoContextLines.push(
290
- "This is a multi-game repo with a shared root package.json. Do not create a per-game package.json or vite config.",
291
- )
292
- if (repoType === "workspace-monorepo") repoContextLines.push("This is a workspace monorepo. The game has its own package.json.")
293
-
294
- p.log.step("Running Puzzmo migration pipeline...")
295
- await runSkillsPipelineTUI(selectedAgent, gameDir, repoContextLines.join("\n"))
380
+ // Step 7: Strategy-specific agent work
381
+ if (strategy === "import") {
382
+ const selectedAgent = await pickAgent(opts.agent)
383
+ if (selectedAgent !== "none") {
384
+ const repoContextLines = [
385
+ `Repo type: ${repoType}`,
386
+ `Package manager: ${repo.packageManager} (use this instead of npm/npx when running commands or adding dependencies)`,
387
+ ]
388
+ if (repoType === "multi-game")
389
+ repoContextLines.push(
390
+ "This is a multi-game repo with a shared root package.json. Do not create a per-game package.json or vite config.",
391
+ )
392
+ if (repoType === "workspace-monorepo") repoContextLines.push("This is a workspace monorepo. The game has its own package.json.")
393
+
394
+ p.log.step("Running Puzzmo migration pipeline...")
395
+ await runSkillsPipelineTUI(selectedAgent, gameDir, repoContextLines.join("\n"))
396
+ }
397
+ } else if (strategy === "prompt") {
398
+ const selectedAgent = await pickAgent(opts.agent)
399
+ const message = buildPromptStrategyMessage(userPrompt!, name)
400
+ if (selectedAgent === "none") {
401
+ p.log.warn("No agent selected; skipping LLM customization.")
402
+ p.note(message, "Paste this prompt into your LLM agent:")
403
+ } else {
404
+ p.log.step("Running agent with your prompt...")
405
+ const ok = runAgentWithBuildLoop(selectedAgent, message, gameDir, "from-prompt")
406
+ if (!ok) p.log.warn("Agent step did not complete cleanly. The Minesweeper starter is still in place.")
407
+ }
296
408
  }
297
409
 
298
410
  // Done
@@ -2,16 +2,35 @@ import { execSync } from "node:child_process"
2
2
  import crypto from "node:crypto"
3
3
  import fs from "node:fs"
4
4
  import path from "node:path"
5
+ import * as p from "@clack/prompts"
5
6
 
6
- import { uploadFiles } from "../util/api.js"
7
+ import { GameNotFoundError, uploadFiles } from "../util/api.js"
7
8
  import { getAPIURL, getToken } from "../util/config.js"
8
- import { validatePuzzmoJson } from "../util/validatePuzzmoFile.js"
9
+ import { createUserGame } from "../util/createGame.js"
10
+ import { discoverGames, type DiscoveredGame } from "../util/discoverGames.js"
9
11
 
10
12
  type UploadOptions = {
11
13
  verbose?: boolean
12
14
  }
13
15
 
14
- /** Uploads game build artifacts to Puzzmo */
16
+ type GameSuccess = {
17
+ ok: true
18
+ slug: string
19
+ fileCount: number
20
+ totalBytes: number
21
+ versionID: string
22
+ assetsBase: string
23
+ }
24
+
25
+ type GameFailure = {
26
+ ok: false
27
+ slug: string
28
+ error: string
29
+ }
30
+
31
+ type GameResult = GameSuccess | GameFailure
32
+
33
+ /** Uploads game build artifacts to Puzzmo by discovering puzzmo.json files in `dir` */
15
34
  export const upload = async (dir: string, options: UploadOptions = {}) => {
16
35
  const { verbose = false } = options
17
36
  const token = getToken()
@@ -20,68 +39,95 @@ export const upload = async (dir: string, options: UploadOptions = {}) => {
20
39
  process.exit(1)
21
40
  }
22
41
 
23
- const distDir = path.resolve(dir)
24
- if (!fs.existsSync(distDir)) {
42
+ const rootDir = path.resolve(dir)
43
+ if (!fs.existsSync(rootDir)) {
25
44
  console.error(`Directory not found: ${dir}`)
26
45
  process.exit(1)
27
46
  }
28
47
 
29
- const files = collectFiles(distDir)
30
- if (!files.length) {
31
- console.error(`Directory is empty: ${dir}`)
48
+ const { games, errors: discoveryErrors } = await discoverGames(rootDir)
49
+
50
+ if (!games.length && !discoveryErrors.length) {
51
+ console.error(`No puzzmo.json files found under ${rootDir}`)
32
52
  process.exit(1)
33
53
  }
34
54
 
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)
55
+ console.log(`Found ${games.length} game(s) under ${rootDir}`)
56
+ for (const game of games) {
57
+ console.log(` - ${game.puzzmoFile.game.slug} (${path.relative(rootDir, game.distDir) || "."})`)
58
+ }
59
+ if (discoveryErrors.length) {
60
+ console.log(`\n${discoveryErrors.length} puzzmo.json file(s) could not be loaded:`)
61
+ for (const err of discoveryErrors) {
62
+ console.log(` - ${err.slug ?? path.relative(rootDir, err.puzzmoJsonPath)}`)
63
+ for (const e of err.errors) console.log(` ${e}`)
64
+ }
41
65
  }
42
66
 
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)
67
+ const sha = getGitSHA(rootDir) || hashGames(games)
68
+ const description = getGitMessage(rootDir)
69
+ const repoURL = getGitRepoURL(rootDir)
70
+ if (description) console.log(`\nMessage: ${description}`)
71
+ if (repoURL) console.log(`Repo: ${repoURL}`)
72
+
73
+ const apiURL = getAPIURL()
74
+ const defaultURL = "https://api.puzzmo.com"
75
+ if (apiURL !== defaultURL) console.log(`API: ${apiURL}`)
76
+
77
+ const results: GameResult[] = []
78
+ for (const err of discoveryErrors) {
79
+ results.push({ ok: false, slug: err.slug ?? path.relative(rootDir, err.puzzmoJsonPath), error: err.errors.join("; ") })
49
80
  }
50
81
 
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)
82
+ for (let i = 0; i < games.length; i++) {
83
+ const game = games[i]
84
+ console.log(`\n[${i + 1}/${games.length}] Uploading ${game.puzzmoFile.game.slug}`)
85
+ try {
86
+ let result: GameSuccess
87
+ try {
88
+ result = await uploadOneGame(game, { token, sha, description, repoURL, verbose, rootDir })
89
+ } catch (e) {
90
+ if (!(e instanceof GameNotFoundError)) throw e
91
+ const created = await maybeCreateMissingGame(e, game, { teamAccessToken: token })
92
+ if (!created) throw e
93
+ result = await uploadOneGame(game, { token, sha, description, repoURL, verbose, rootDir })
94
+ }
95
+ results.push(result)
96
+ } catch (e) {
97
+ const message = e instanceof Error ? e.message : String(e)
98
+ console.error(` Failed: ${message}`)
99
+ results.push({ ok: false, slug: game.puzzmoFile.game.slug, error: message })
100
+ }
56
101
  }
57
- const puzzmoFile = validation.data
58
- const gameSlug = puzzmoFile.game.slug
59
102
 
60
- // Determine SHA
61
- const sha = getGitSHA() || hashFiles(files)
62
- const description = getGitMessage()
63
- const repoURL = getGitRepoURL()
103
+ printReport(results)
64
104
 
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("")
105
+ const anyFailed = results.some((r) => !r.ok)
106
+ if (anyFailed) process.exit(1)
107
+ }
70
108
 
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
- }
109
+ type UploadOneOptions = {
110
+ token: string
111
+ sha: string
112
+ description: string | null
113
+ repoURL: string | null
114
+ verbose: boolean
115
+ rootDir: string
116
+ }
78
117
 
79
- const apiURL = getAPIURL()
80
- const defaultURL = "https://api.puzzmo.com"
118
+ /** Uploads a single discovered game; throws on failure */
119
+ const uploadOneGame = async (game: DiscoveredGame, opts: UploadOneOptions): Promise<GameSuccess> => {
120
+ const { token, sha, description, repoURL, verbose, rootDir } = opts
121
+ const { puzzmoFile, distDir } = game
122
+ const gameSlug = puzzmoFile.game.slug
81
123
 
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...")
124
+ const files = collectFiles(distDir)
125
+ if (!files.length) throw new Error(`Dist folder is empty: ${distDir}`)
126
+
127
+ console.log(` Directory: ${path.relative(rootDir, distDir) || distDir}`)
128
+ let totalBytes = 0
129
+ for (const file of files) totalBytes += fs.statSync(file).size
130
+ console.log(` ${files.length} file(s), ${formatBytes(totalBytes)} total (sha ${sha.slice(0, 8)})`)
85
131
 
86
132
  const result = await uploadFiles(
87
133
  token,
@@ -91,13 +137,34 @@ export const upload = async (dir: string, options: UploadOptions = {}) => {
91
137
  distDir,
92
138
  puzzmoFile,
93
139
  (batch, totalBatches, uploaded) => {
94
- console.log(` Batch ${batch}/${totalBatches} done (${uploaded} file(s) uploaded)`)
140
+ console.log(` Batch ${batch}/${totalBatches} done (${uploaded} file(s) uploaded)`)
95
141
  },
96
142
  { verbose, description, repoURL },
97
143
  )
98
144
 
99
- console.log(`\nDone - ${result.versionID}`)
100
- console.log(`Assets: ${result.assetsBase}`)
145
+ console.log(` Done - ${result.versionID}`)
146
+ return {
147
+ ok: true,
148
+ slug: gameSlug,
149
+ fileCount: files.length,
150
+ totalBytes,
151
+ versionID: result.versionID,
152
+ assetsBase: result.assetsBase,
153
+ }
154
+ }
155
+
156
+ /** Prints a final summary of every game's outcome */
157
+ const printReport = (results: GameResult[]) => {
158
+ console.log(`\nUpload report (${results.length} game${results.length === 1 ? "" : "s"})`)
159
+ const successes = results.filter((r): r is GameSuccess => r.ok)
160
+ const failures = results.filter((r): r is GameFailure => !r.ok)
161
+ for (const r of successes) {
162
+ console.log(` OK ${r.slug.padEnd(24)} ${formatBytes(r.totalBytes).padStart(8)} ${r.versionID}`)
163
+ }
164
+ for (const r of failures) {
165
+ console.log(` FAIL ${r.slug.padEnd(24)} ${r.error}`)
166
+ }
167
+ console.log(`\n${successes.length} succeeded, ${failures.length} failed`)
101
168
  }
102
169
 
103
170
  /** Collects all files in a directory recursively */
@@ -113,27 +180,27 @@ const collectFiles = (dir: string): string[] => {
113
180
  }
114
181
 
115
182
  /** Tries to get the shortest unique git SHA, returns null if not in a git repo */
116
- const getGitSHA = (): string | null => {
183
+ const getGitSHA = (cwd: string): string | null => {
117
184
  try {
118
- return execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim()
185
+ return execSync("git rev-parse --short HEAD", { encoding: "utf-8", cwd }).trim()
119
186
  } catch {
120
187
  return null
121
188
  }
122
189
  }
123
190
 
124
191
  /** Gets the subject line of the latest commit, or null if not in a git repo */
125
- const getGitMessage = (): string | null => {
192
+ const getGitMessage = (cwd: string): string | null => {
126
193
  try {
127
- return execSync("git log -1 --pretty=%s", { encoding: "utf-8" }).trim() || null
194
+ return execSync("git log -1 --pretty=%s", { encoding: "utf-8", cwd }).trim() || null
128
195
  } catch {
129
196
  return null
130
197
  }
131
198
  }
132
199
 
133
200
  /** Gets the origin remote URL normalized to https, or null if unavailable */
134
- const getGitRepoURL = (): string | null => {
201
+ const getGitRepoURL = (cwd: string): string | null => {
135
202
  try {
136
- const raw = execSync("git config --get remote.origin.url", { encoding: "utf-8" }).trim()
203
+ const raw = execSync("git config --get remote.origin.url", { encoding: "utf-8", cwd }).trim()
137
204
  return raw ? normalizeRepoURL(raw) : null
138
205
  } catch {
139
206
  return null
@@ -153,15 +220,58 @@ const normalizeRepoURL = (url: string): string => {
153
220
  return normalized.replace(/\.git$/, "")
154
221
  }
155
222
 
156
- /** Hashes all file contents to produce a deterministic SHA */
157
- const hashFiles = (filePaths: string[]): string => {
223
+ /** Hashes the dist contents of every game to produce a deterministic SHA across all uploads */
224
+ const hashGames = (games: DiscoveredGame[]): string => {
158
225
  const hash = crypto.createHash("sha256")
159
- for (const fp of filePaths.sort()) {
160
- hash.update(fs.readFileSync(fp))
226
+ for (const game of games) {
227
+ for (const file of collectFiles(game.distDir).sort()) {
228
+ hash.update(file)
229
+ hash.update(fs.readFileSync(file))
230
+ }
161
231
  }
162
232
  return hash.digest("hex").slice(0, 12)
163
233
  }
164
234
 
235
+ /**
236
+ * When a game can't be uploaded because it doesn't exist on the user's account,
237
+ * offer to create it interactively. Returns true if the game was created and the
238
+ * caller can retry the upload. Returns false otherwise (non-interactive shell or
239
+ * user declined).
240
+ */
241
+ const maybeCreateMissingGame = async (
242
+ err: GameNotFoundError,
243
+ game: DiscoveredGame,
244
+ opts: { teamAccessToken: string },
245
+ ): Promise<boolean> => {
246
+ const slug = err.slug
247
+ const displayName = game.puzzmoFile.game.displayName
248
+
249
+ if (!process.stdin.isTTY) {
250
+ console.error(` Game "${slug}" not found on your account. Re-run interactively to create it.`)
251
+ return false
252
+ }
253
+
254
+ const consent = await p.confirm({
255
+ message: `Game "${slug}" doesn't exist on your account. Create it now (displayName: "${displayName}")?`,
256
+ initialValue: true,
257
+ })
258
+ if (p.isCancel(consent) || !consent) return false
259
+
260
+ try {
261
+ const created = await createUserGame({ displayName, teamAccessToken: opts.teamAccessToken })
262
+ console.log(` Created game ${created.slug} (${created.id})`)
263
+ if (created.slug !== slug) {
264
+ console.warn(` Server picked slug "${created.slug}" but your puzzmo.json uses "${slug}". Update puzzmo.json before re-uploading.`)
265
+ return false
266
+ }
267
+ return true
268
+ } catch (e) {
269
+ const message = e instanceof Error ? e.message : String(e)
270
+ console.error(` Could not create game: ${message}`)
271
+ return false
272
+ }
273
+ }
274
+
165
275
  /** Formats a byte count as a human-readable string */
166
276
  const formatBytes = (bytes: number): string => {
167
277
  if (bytes < 1024) return `${bytes} B`