@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.
- package/lib/commands/game/create.js +187 -71
- package/lib/commands/game/create.js.map +1 -1
- package/lib/commands/upload.d.ts +1 -1
- package/lib/commands/upload.js +143 -64
- package/lib/commands/upload.js.map +1 -1
- package/lib/commands/validate.d.ts +1 -1
- package/lib/commands/validate.js +24 -31
- package/lib/commands/validate.js.map +1 -1
- package/lib/index.js +4 -8
- package/lib/index.js.map +1 -1
- package/lib/skills/runner.d.ts +2 -0
- package/lib/skills/runner.js +33 -0
- package/lib/skills/runner.js.map +1 -1
- package/lib/util/api.d.ts +8 -0
- package/lib/util/api.js +22 -3
- package/lib/util/api.js.map +1 -1
- package/lib/util/createGame.d.ts +13 -0
- package/lib/util/createGame.js +36 -0
- package/lib/util/createGame.js.map +1 -0
- package/lib/util/discoverGames.d.ts +25 -0
- package/lib/util/discoverGames.js +181 -0
- package/lib/util/discoverGames.js.map +1 -0
- package/lib/util/validatePuzzmoFile.d.ts +1 -1
- package/lib/util/validatePuzzmoFile.js +2 -2
- package/lib/util/validatePuzzmoFile.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/game/create.ts +186 -74
- package/src/commands/upload.ts +171 -61
- package/src/commands/validate.ts +23 -30
- package/src/index.ts +6 -8
- package/src/skills/runner.ts +35 -0
- package/src/util/api.ts +32 -9
- package/src/util/createGame.ts +60 -0
- package/src/util/discoverGames.ts +203 -0
- package/src/util/validatePuzzmoFile.ts +2 -2
- package/templates/minesweeper/README.md +13 -0
- package/templates/minesweeper/fixtures/puzzles/easy/9x9.json +16 -0
- package/templates/minesweeper/fixtures/puzzles/hard/16x16.json +46 -0
- package/templates/minesweeper/index.html +19 -0
- package/templates/minesweeper/package.json +17 -0
- package/templates/minesweeper/puzzmo.json +11 -0
- package/templates/minesweeper/src/appBundle.ts +122 -0
- package/templates/minesweeper/src/main.ts +236 -0
- package/templates/minesweeper/src/style.css +160 -0
- package/templates/minesweeper/tsconfig.json +14 -0
- 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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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(/[
|
|
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:
|
|
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
|
|
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")
|
|
262
|
+
if (canAddToRepo && repo.repoType !== "none") placementOptions.reverse()
|
|
177
263
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
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:
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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:
|
|
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:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
package/src/commands/upload.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
24
|
-
if (!fs.existsSync(
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
console.
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
61
|
-
const sha = getGitSHA() || hashFiles(files)
|
|
62
|
-
const description = getGitMessage()
|
|
63
|
-
const repoURL = getGitRepoURL()
|
|
103
|
+
printReport(results)
|
|
64
104
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
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(`
|
|
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(
|
|
100
|
-
|
|
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
|
|
157
|
-
const
|
|
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
|
|
160
|
-
|
|
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`
|