@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
package/src/commands/validate.ts
CHANGED
|
@@ -1,46 +1,39 @@
|
|
|
1
1
|
import fs from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { discoverGames } from "../util/discoverGames.js"
|
|
5
5
|
|
|
6
|
-
/** CLI command: puzzmo validate [dir] */
|
|
6
|
+
/** CLI command: puzzmo validate [dir] — discovers every puzzmo.json under dir and validates each */
|
|
7
7
|
export const validate = async (dir: string) => {
|
|
8
|
-
const
|
|
9
|
-
|
|
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}`)
|
|
13
11
|
process.exit(1)
|
|
14
12
|
}
|
|
15
13
|
|
|
16
|
-
|
|
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)
|
|
23
15
|
|
|
24
|
-
|
|
25
|
-
|
|
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}`)
|
|
29
18
|
process.exit(1)
|
|
30
19
|
}
|
|
31
20
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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(", ")}`)
|
|
37
28
|
}
|
|
38
29
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
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}`)
|
|
45
35
|
}
|
|
36
|
+
|
|
37
|
+
console.log(`\n${games.length} valid, ${errors.length} invalid`)
|
|
38
|
+
if (errors.length) process.exit(1)
|
|
46
39
|
}
|
package/src/index.ts
CHANGED
|
@@ -14,8 +14,8 @@ const printUsage = () => {
|
|
|
14
14
|
puzzmo login <token> Save a CLI auth token
|
|
15
15
|
puzzmo game create [token] Create a new Puzzmo game project
|
|
16
16
|
|
|
17
|
-
puzzmo upload
|
|
18
|
-
puzzmo validate [dir]
|
|
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. If a game does not exist on your account and stdin is a TTY, you'll be asked whether to create it (using your saved CLI token).
|
|
18
|
+
puzzmo validate [dir] Discover and validate every puzzmo.json under [dir] (default: .)
|
|
19
19
|
puzzmo migrate List and select migration skills from dev.puzzmo.com`)
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -32,11 +32,7 @@ const run = async () => {
|
|
|
32
32
|
}
|
|
33
33
|
case "upload": {
|
|
34
34
|
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("-")) ?? "."
|
|
40
36
|
await upload(dir, { verbose })
|
|
41
37
|
break
|
|
42
38
|
}
|
|
@@ -45,7 +41,9 @@ const run = async () => {
|
|
|
45
41
|
if (subcommand === "create") {
|
|
46
42
|
await gameCreate(subArgs)
|
|
47
43
|
} else {
|
|
48
|
-
console.error(
|
|
44
|
+
console.error(
|
|
45
|
+
"Usage: puzzmo game create [--strategy <import|blank|prompt>] [--prompt <text>] [--name <name>] [--url <url>] [--agent <agent>] [--token <token>] [--pm <npm|yarn|pnpm>]",
|
|
46
|
+
)
|
|
49
47
|
process.exit(1)
|
|
50
48
|
}
|
|
51
49
|
break
|
package/src/skills/runner.ts
CHANGED
|
@@ -82,3 +82,38 @@ export const runSkillsPipeline = async (agent: string, gameDir: string, repoCont
|
|
|
82
82
|
export const runSkillsPipelineTUI = async (agent: string, gameDir: string, repoContext: string) => {
|
|
83
83
|
await runPipelineTUI(agent, gameDir, repoContext)
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
/** Runs a single agent invocation, verifies the build, and commits. Used by one-shot prompt-driven flows. */
|
|
87
|
+
export const runAgentWithBuildLoop = (agent: string, prompt: string, gameDir: string, label: string): boolean => {
|
|
88
|
+
console.log(`Running agent: ${label}`)
|
|
89
|
+
let result = invokeAgent(agent, prompt, gameDir)
|
|
90
|
+
if (!result.success) {
|
|
91
|
+
console.log(` Agent failed, retrying...`)
|
|
92
|
+
result = invokeAgent(agent, prompt, gameDir)
|
|
93
|
+
if (!result.success) {
|
|
94
|
+
console.error(` Agent failed after retry. Stopping.`)
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const buildResult = verifyBuild(gameDir)
|
|
100
|
+
if (!buildResult.success) {
|
|
101
|
+
console.log(` Build failed, asking agent to fix...`)
|
|
102
|
+
const fixPrompt = `The vite build failed after the "${label}" step. Fix the build errors:\n\n${buildResult.error}`
|
|
103
|
+
invokeAgent(agent, fixPrompt, gameDir)
|
|
104
|
+
const retry = verifyBuild(gameDir)
|
|
105
|
+
if (!retry.success) {
|
|
106
|
+
console.error(` Build still failing after fix attempt.`)
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
runCommand("git add -A", { cwd: gameDir })
|
|
113
|
+
gitCommit(`step: ${label}`, { cwd: gameDir })
|
|
114
|
+
console.log(` Committed.`)
|
|
115
|
+
} catch {
|
|
116
|
+
console.log(` No changes to commit.`)
|
|
117
|
+
}
|
|
118
|
+
return true
|
|
119
|
+
}
|
package/src/util/api.ts
CHANGED
|
@@ -10,11 +10,26 @@ export type PuzzmoFile = {
|
|
|
10
10
|
slug: string
|
|
11
11
|
teamID: string
|
|
12
12
|
}
|
|
13
|
+
// This is what we're calling 'augmentations' publicly
|
|
13
14
|
integrations?: Record<string, unknown>
|
|
15
|
+
output?: {
|
|
16
|
+
dir: string
|
|
17
|
+
}
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
const BATCH_SIZE = 10
|
|
17
21
|
|
|
22
|
+
/** Thrown when /cliUpload init returns a 404 because the game slug isn't registered yet. */
|
|
23
|
+
export class GameNotFoundError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
public readonly slug: string,
|
|
26
|
+
message: string,
|
|
27
|
+
) {
|
|
28
|
+
super(message)
|
|
29
|
+
this.name = "GameNotFoundError"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
type InitResponse = { sessionID: string; basePath: string; error?: string }
|
|
19
34
|
type FileResponse = { path: string; error?: string }
|
|
20
35
|
type CompleteResponse = { assetsBase: string; versionID: string; error?: string }
|
|
@@ -60,9 +75,11 @@ const readResponse = async (res: Response, url: string, step: string, verbose: b
|
|
|
60
75
|
throw new Error(`Invalid JSON response during ${step} (${url}): ${verbose ? text : text.slice(0, 200)}`)
|
|
61
76
|
}
|
|
62
77
|
if (!res.ok) {
|
|
63
|
-
const
|
|
78
|
+
const body = json as { error?: string; validationErrors?: string[] }
|
|
79
|
+
const serverMsg = body.error || res.statusText || "Unknown error"
|
|
80
|
+
const validation = body.validationErrors?.length ? `\n - ${body.validationErrors.join("\n - ")}` : ""
|
|
64
81
|
const detail = verbose ? `\n${JSON.stringify(json, null, 2)}` : ""
|
|
65
|
-
throw new Error(`Server error during ${step} (${res.status} from ${url}): ${serverMsg}${detail}`)
|
|
82
|
+
throw new Error(`Server error during ${step} (${res.status} from ${url}): ${serverMsg}${validation}${detail}`)
|
|
66
83
|
}
|
|
67
84
|
return json
|
|
68
85
|
}
|
|
@@ -121,13 +138,19 @@ export const uploadFiles = async (
|
|
|
121
138
|
if (verbose) console.log(` API URL: ${apiURL}`)
|
|
122
139
|
|
|
123
140
|
// Step 1: Init session (includes puzzmo.json metadata)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
141
|
+
let init: InitResponse
|
|
142
|
+
try {
|
|
143
|
+
init = (await jsonPost(
|
|
144
|
+
`${apiURL}/cliUpload`,
|
|
145
|
+
token,
|
|
146
|
+
{ gameSlug, sha, puzzmoFile, description, repoURL },
|
|
147
|
+
"upload init",
|
|
148
|
+
verbose,
|
|
149
|
+
)) as InitResponse
|
|
150
|
+
} catch (e) {
|
|
151
|
+
if (e instanceof Error && e.message.includes("Game not found:")) throw new GameNotFoundError(gameSlug, e.message)
|
|
152
|
+
throw e
|
|
153
|
+
}
|
|
131
154
|
if (verbose) console.log(` session: ${init.sessionID}`)
|
|
132
155
|
|
|
133
156
|
// Step 2: Upload files in concurrent batches
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getAPIURL } from "./config.js"
|
|
2
|
+
|
|
3
|
+
const createUserGameMutation = `
|
|
4
|
+
mutation cliCreateUserGameMutation($teamAccessToken: String!, $input: CreateUserGameInput!) {
|
|
5
|
+
createUserGame(teamAccessToken: $teamAccessToken, input: $input) {
|
|
6
|
+
game { id slug displayName }
|
|
7
|
+
newAccessToken
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
`
|
|
11
|
+
|
|
12
|
+
type GraphQLError = { message: string }
|
|
13
|
+
|
|
14
|
+
type CreateUserGameResponse = {
|
|
15
|
+
data?: {
|
|
16
|
+
createUserGame: {
|
|
17
|
+
game: { id: string; slug: string; displayName: string }
|
|
18
|
+
newAccessToken: string | null
|
|
19
|
+
} | null
|
|
20
|
+
}
|
|
21
|
+
errors?: GraphQLError[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type CreateUserGameOptions = {
|
|
25
|
+
/** A teamAccessToken (the same token saved by `puzzmo login`). The mutation uses it to identify the team. */
|
|
26
|
+
teamAccessToken: string
|
|
27
|
+
displayName: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type CreatedGame = { id: string; slug: string; displayName: string; newAccessToken: string | null }
|
|
31
|
+
|
|
32
|
+
/** Calls the createUserGame mutation using a teamAccessToken for auth. */
|
|
33
|
+
export const createUserGame = async (options: CreateUserGameOptions): Promise<CreatedGame> => {
|
|
34
|
+
const url = `${getAPIURL()}/graphql`
|
|
35
|
+
|
|
36
|
+
const response = await fetch(url, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
query: createUserGameMutation,
|
|
41
|
+
variables: {
|
|
42
|
+
teamAccessToken: options.teamAccessToken,
|
|
43
|
+
input: { displayName: options.displayName },
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
if (!response.ok) throw new Error(`createUserGame request failed: ${response.status} ${response.statusText}`)
|
|
49
|
+
|
|
50
|
+
const result = (await response.json()) as CreateUserGameResponse
|
|
51
|
+
|
|
52
|
+
if (result.errors?.length) {
|
|
53
|
+
const message = result.errors.map((e) => e.message).join("; ")
|
|
54
|
+
throw new Error(`createUserGame failed: ${message}`)
|
|
55
|
+
}
|
|
56
|
+
if (!result.data?.createUserGame) throw new Error("createUserGame returned no data")
|
|
57
|
+
|
|
58
|
+
const { game, newAccessToken } = result.data.createUserGame
|
|
59
|
+
return { id: game.id, slug: game.slug, displayName: game.displayName, newAccessToken }
|
|
60
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { PuzzmoFile } from "./api.js"
|
|
5
|
+
import { validatePuzzmoJson } from "./validatePuzzmoFile.js"
|
|
6
|
+
|
|
7
|
+
/** Folder names that are skipped while walking the repo for puzzmo.json files */
|
|
8
|
+
const ignoredDirs = new Set(["node_modules", ".git", ".turbo", ".next", ".cache", "dist", "build", "built", "output"])
|
|
9
|
+
|
|
10
|
+
/** Folder names that are searched (in order) when resolving a game's dist directory */
|
|
11
|
+
const outputDirNames = ["dist", "build", "built", "output"]
|
|
12
|
+
|
|
13
|
+
/** A game discovered in the repo, with its parsed puzzmo.json and resolved dist directory */
|
|
14
|
+
export type DiscoveredGame = {
|
|
15
|
+
/** Absolute path to the puzzmo.json file */
|
|
16
|
+
puzzmoJsonPath: string
|
|
17
|
+
/** Directory containing the puzzmo.json file */
|
|
18
|
+
puzzmoJsonDir: string
|
|
19
|
+
/** Validated puzzmo.json contents */
|
|
20
|
+
puzzmoFile: PuzzmoFile
|
|
21
|
+
/** Absolute path to the dist directory containing the build artifacts */
|
|
22
|
+
distDir: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A puzzmo.json that was found but could not be used (invalid JSON, schema errors, missing dist) */
|
|
26
|
+
export type DiscoveryError = {
|
|
27
|
+
puzzmoJsonPath: string
|
|
28
|
+
/** Slug pulled from the file when available, used for reporting */
|
|
29
|
+
slug?: string
|
|
30
|
+
errors: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type DiscoveryResult = {
|
|
34
|
+
games: DiscoveredGame[]
|
|
35
|
+
errors: DiscoveryError[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Walks `rootDir` looking for puzzmo.json files, validates each, and resolves their dist directory */
|
|
39
|
+
export const discoverGames = async (rootDir: string): Promise<DiscoveryResult> => {
|
|
40
|
+
const root = path.resolve(rootDir)
|
|
41
|
+
const puzzmoJsonPaths = findPuzzmoJsonFiles(root)
|
|
42
|
+
|
|
43
|
+
const games: DiscoveredGame[] = []
|
|
44
|
+
const errors: DiscoveryError[] = []
|
|
45
|
+
|
|
46
|
+
for (const puzzmoJsonPath of puzzmoJsonPaths) {
|
|
47
|
+
const fileErrors: string[] = []
|
|
48
|
+
let parsed: unknown
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(fs.readFileSync(puzzmoJsonPath, "utf-8"))
|
|
51
|
+
} catch (e) {
|
|
52
|
+
errors.push({ puzzmoJsonPath, errors: [`Invalid JSON: ${e instanceof Error ? e.message : e}`] })
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const validation = await validatePuzzmoJson(parsed)
|
|
57
|
+
if (!validation.valid) {
|
|
58
|
+
const slug = pullSlug(parsed)
|
|
59
|
+
errors.push({ puzzmoJsonPath, slug, errors: validation.errors })
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const puzzmoFile = validation.data
|
|
64
|
+
const puzzmoJsonDir = path.dirname(puzzmoJsonPath)
|
|
65
|
+
const distDir = resolveDistDir(puzzmoFile, puzzmoJsonDir, root)
|
|
66
|
+
|
|
67
|
+
if (!distDir) {
|
|
68
|
+
fileErrors.push(`Could not find a dist/build folder for ${puzzmoFile.game.slug}. Set "output.dir" in puzzmo.json.`)
|
|
69
|
+
} else if (!hasFiles(distDir)) {
|
|
70
|
+
fileErrors.push(`Dist folder is empty: ${distDir}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (fileErrors.length) {
|
|
74
|
+
errors.push({ puzzmoJsonPath, slug: puzzmoFile.game.slug, errors: fileErrors })
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
games.push({ puzzmoJsonPath, puzzmoJsonDir, puzzmoFile, distDir: distDir as string })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { games, errors }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Recursively finds puzzmo.json files under `dir`, skipping output / vendored folders. Tracks realpaths to avoid symlink cycles. */
|
|
85
|
+
const findPuzzmoJsonFiles = (dir: string): string[] => {
|
|
86
|
+
const found: string[] = []
|
|
87
|
+
const visited = new Set<string>()
|
|
88
|
+
const stack = [dir]
|
|
89
|
+
while (stack.length) {
|
|
90
|
+
const current = stack.pop() as string
|
|
91
|
+
const realCurrent = safeRealpath(current)
|
|
92
|
+
if (!realCurrent || visited.has(realCurrent)) continue
|
|
93
|
+
visited.add(realCurrent)
|
|
94
|
+
|
|
95
|
+
let entries: fs.Dirent[]
|
|
96
|
+
try {
|
|
97
|
+
entries = fs.readdirSync(realCurrent, { withFileTypes: true })
|
|
98
|
+
} catch {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
const full = path.join(realCurrent, entry.name)
|
|
103
|
+
const kind = resolveEntryKind(entry, full)
|
|
104
|
+
if (kind === "directory") {
|
|
105
|
+
if (ignoredDirs.has(entry.name)) continue
|
|
106
|
+
if (entry.name.startsWith(".")) continue
|
|
107
|
+
stack.push(full)
|
|
108
|
+
} else if (kind === "file" && entry.name === "puzzmo.json") {
|
|
109
|
+
found.push(full)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return found.sort()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Resolves the dist directory for a discovered game, returning null if none can be found */
|
|
117
|
+
const resolveDistDir = (puzzmoFile: PuzzmoFile, puzzmoJsonDir: string, repoRoot: string): string | null => {
|
|
118
|
+
if (puzzmoFile.output?.dir) {
|
|
119
|
+
const resolved = path.resolve(puzzmoJsonDir, puzzmoFile.output.dir)
|
|
120
|
+
return fs.existsSync(resolved) ? resolved : null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const slug = puzzmoFile.game.slug
|
|
124
|
+
const candidateDirs = walkUp(puzzmoJsonDir, repoRoot)
|
|
125
|
+
for (const baseDir of candidateDirs) {
|
|
126
|
+
for (const name of outputDirNames) {
|
|
127
|
+
const withSlug = path.join(baseDir, name, slug)
|
|
128
|
+
if (fs.existsSync(withSlug) && fs.statSync(withSlug).isDirectory()) return withSlug
|
|
129
|
+
const plain = path.join(baseDir, name)
|
|
130
|
+
if (fs.existsSync(plain) && fs.statSync(plain).isDirectory()) return plain
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Returns the directories from `start` walking up to (and including) `stop` */
|
|
137
|
+
const walkUp = (start: string, stop: string): string[] => {
|
|
138
|
+
const dirs: string[] = []
|
|
139
|
+
let current = start
|
|
140
|
+
while (true) {
|
|
141
|
+
dirs.push(current)
|
|
142
|
+
if (current === stop) break
|
|
143
|
+
const parent = path.dirname(current)
|
|
144
|
+
if (parent === current) break
|
|
145
|
+
if (!current.startsWith(stop)) break
|
|
146
|
+
current = parent
|
|
147
|
+
}
|
|
148
|
+
return dirs
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Returns true if the directory contains at least one file (recursively). Follows symlinks safely via a visited realpath set. */
|
|
152
|
+
const hasFiles = (dir: string, visited: Set<string> = new Set()): boolean => {
|
|
153
|
+
const realDir = safeRealpath(dir)
|
|
154
|
+
if (!realDir || visited.has(realDir)) return false
|
|
155
|
+
visited.add(realDir)
|
|
156
|
+
|
|
157
|
+
let entries: fs.Dirent[]
|
|
158
|
+
try {
|
|
159
|
+
entries = fs.readdirSync(realDir, { withFileTypes: true })
|
|
160
|
+
} catch {
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
const full = path.join(realDir, entry.name)
|
|
165
|
+
const kind = resolveEntryKind(entry, full)
|
|
166
|
+
if (kind === "file") return true
|
|
167
|
+
if (kind === "directory" && hasFiles(full, visited)) return true
|
|
168
|
+
}
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Resolves the underlying type of a directory entry, following symlinks one level. Returns null for dangling links or other types. */
|
|
173
|
+
const resolveEntryKind = (entry: fs.Dirent, fullPath: string): "file" | "directory" | null => {
|
|
174
|
+
if (entry.isFile()) return "file"
|
|
175
|
+
if (entry.isDirectory()) return "directory"
|
|
176
|
+
if (!entry.isSymbolicLink()) return null
|
|
177
|
+
try {
|
|
178
|
+
const stat = fs.statSync(fullPath)
|
|
179
|
+
if (stat.isFile()) return "file"
|
|
180
|
+
if (stat.isDirectory()) return "directory"
|
|
181
|
+
} catch {
|
|
182
|
+
// dangling symlink
|
|
183
|
+
}
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Resolves a path to its real location; returns null if the path can't be resolved (broken link, missing dir, etc.) */
|
|
188
|
+
const safeRealpath = (p: string): string | null => {
|
|
189
|
+
try {
|
|
190
|
+
return fs.realpathSync(p)
|
|
191
|
+
} catch {
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Best-effort extraction of the slug from a malformed puzzmo.json for error reporting */
|
|
197
|
+
const pullSlug = (parsed: unknown): string | undefined => {
|
|
198
|
+
if (!parsed || typeof parsed !== "object") return undefined
|
|
199
|
+
const game = (parsed as { game?: unknown }).game
|
|
200
|
+
if (!game || typeof game !== "object") return undefined
|
|
201
|
+
const slug = (game as { slug?: unknown }).slug
|
|
202
|
+
return typeof slug === "string" ? slug : undefined
|
|
203
|
+
}
|
|
@@ -32,12 +32,12 @@ const getValidator = async () => {
|
|
|
32
32
|
|
|
33
33
|
type ValidationResult = { valid: true; data: PuzzmoFile } | { valid: false; errors: string[] }
|
|
34
34
|
|
|
35
|
-
/** Validates a parsed object against the puzzmo.json JSON schema */
|
|
35
|
+
/** Validates a parsed object against the puzzmo.json JSON schema. Returned `data` has `$schema` stripped. */
|
|
36
36
|
export const validatePuzzmoJson = async (data: unknown): Promise<ValidationResult> => {
|
|
37
37
|
const validator = await getValidator()
|
|
38
38
|
const toValidate = stripSchemaProperty(data)
|
|
39
39
|
const result = validator.validate(toValidate)
|
|
40
|
-
if (result.valid) return { valid: true, data:
|
|
40
|
+
if (result.valid) return { valid: true, data: toValidate as PuzzmoFile }
|
|
41
41
|
return {
|
|
42
42
|
valid: false,
|
|
43
43
|
errors: result.errors
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"rows": 16,
|
|
3
|
+
"cols": 16,
|
|
4
|
+
"mines": [
|
|
5
|
+
[0, 3],
|
|
6
|
+
[0, 11],
|
|
7
|
+
[1, 5],
|
|
8
|
+
[1, 14],
|
|
9
|
+
[2, 1],
|
|
10
|
+
[2, 8],
|
|
11
|
+
[2, 12],
|
|
12
|
+
[3, 4],
|
|
13
|
+
[3, 9],
|
|
14
|
+
[4, 0],
|
|
15
|
+
[4, 6],
|
|
16
|
+
[4, 13],
|
|
17
|
+
[5, 2],
|
|
18
|
+
[5, 10],
|
|
19
|
+
[5, 15],
|
|
20
|
+
[6, 5],
|
|
21
|
+
[6, 8],
|
|
22
|
+
[7, 1],
|
|
23
|
+
[7, 11],
|
|
24
|
+
[7, 14],
|
|
25
|
+
[8, 3],
|
|
26
|
+
[8, 7],
|
|
27
|
+
[9, 0],
|
|
28
|
+
[9, 9],
|
|
29
|
+
[9, 12],
|
|
30
|
+
[10, 5],
|
|
31
|
+
[10, 14],
|
|
32
|
+
[11, 2],
|
|
33
|
+
[11, 8],
|
|
34
|
+
[11, 11],
|
|
35
|
+
[12, 0],
|
|
36
|
+
[12, 6],
|
|
37
|
+
[12, 13],
|
|
38
|
+
[13, 4],
|
|
39
|
+
[13, 9],
|
|
40
|
+
[13, 15],
|
|
41
|
+
[14, 1],
|
|
42
|
+
[14, 7],
|
|
43
|
+
[14, 12],
|
|
44
|
+
[15, 10]
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
|
6
|
+
<title>__DISPLAY_NAME__</title>
|
|
7
|
+
<link rel="stylesheet" href="./src/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main>
|
|
11
|
+
<div class="hud">
|
|
12
|
+
<span id="hudLeft">0 mines</span>
|
|
13
|
+
<span id="hudRight">0 flags</span>
|
|
14
|
+
</div>
|
|
15
|
+
<div id="board" class="board" aria-label="minesweeper board"></div>
|
|
16
|
+
</main>
|
|
17
|
+
<script type="module" src="./src/main.ts"></script>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__SLUG__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@puzzmo/sdk": "*"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"typescript": "^5.4.0",
|
|
15
|
+
"vite": "^5.4.0"
|
|
16
|
+
}
|
|
17
|
+
}
|