@puzzmo/cli 1.0.51 → 1.0.53

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 (35) hide show
  1. package/README.md +12 -2
  2. package/docs/games-changed.md +223 -0
  3. package/lib/commands/changed.d.ts +12 -0
  4. package/lib/commands/changed.js +178 -0
  5. package/lib/commands/changed.js.map +1 -0
  6. package/lib/commands/upload.js +27 -0
  7. package/lib/commands/upload.js.map +1 -1
  8. package/lib/commands/validate.d.ts +1 -1
  9. package/lib/commands/validate.js +1 -1
  10. package/lib/commands/validate.js.map +1 -1
  11. package/lib/index.js +52 -2
  12. package/lib/index.js.map +1 -1
  13. package/lib/queries/__generated__/cliGameRuntimesQuery.graphql.d.ts +37 -0
  14. package/lib/queries/__generated__/cliGameRuntimesQuery.graphql.js +256 -0
  15. package/lib/queries/__generated__/cliGameRuntimesQuery.graphql.js.map +1 -0
  16. package/lib/queries/gameRuntimes.d.ts +14 -0
  17. package/lib/queries/gameRuntimes.js +52 -0
  18. package/lib/queries/gameRuntimes.js.map +1 -0
  19. package/lib/util/belay.d.ts +34 -0
  20. package/lib/util/belay.js +22 -0
  21. package/lib/util/belay.js.map +1 -0
  22. package/lib/util/discoverGames.d.ts +10 -2
  23. package/lib/util/discoverGames.js +14 -11
  24. package/lib/util/discoverGames.js.map +1 -1
  25. package/package.json +1 -1
  26. package/schemas/puzzmo-file-schema.json +1 -1
  27. package/src/commands/changed.ts +239 -0
  28. package/src/commands/upload.ts +30 -0
  29. package/src/commands/validate.ts +1 -1
  30. package/src/index.ts +58 -2
  31. package/src/queries/__generated__/cliGameRuntimesQuery.graphql.ts +301 -0
  32. package/src/queries/gameRuntimes.ts +64 -0
  33. package/src/util/belay.ts +56 -0
  34. package/src/util/discoverGames.ts +23 -11
  35. package/templates/minesweeper/README.md +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@puzzmo/cli",
3
- "version": "1.0.51",
3
+ "version": "1.0.53",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "puzzmo": "./lib/index.js"
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "required": ["dir"],
53
53
  "additionalProperties": false,
54
- "description": "Where the built game artifacts live for this game. Used by `puzzmo upload` and `puzzmo validate` to find the dist folder when running from a repo root. If omitted, the CLI looks for a `dist`, `build`, or `built` folder — first next to puzzmo.json, then walking up to the repo root, optionally with `<slug>` as a sub-folder for shared output dirs."
54
+ "description": "Where the built game artifacts live for this game. Used by `puzzmo games upload` and `puzzmo games validate` to find the dist folder when running from a repo root. If omitted, the CLI looks for a `dist`, `build`, or `built` folder — first next to puzzmo.json, then walking up to the repo root, optionally with `<slug>` as a sub-folder for shared output dirs."
55
55
  }
56
56
  },
57
57
  "required": ["game"],
@@ -0,0 +1,239 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { findTokensForTeam, getTokens, resolveServerForTeam, sourceToURL } from "../util/config.js"
6
+ import { type DiscoveredGame, discoverGames } from "../util/discoverGames.js"
7
+ import { fetchTeamGameVersions, type GameVersions } from "../queries/gameRuntimes.js"
8
+
9
+ /** Which deployed build slot to diff against: the team's latest build, the staged next, or the previous one. */
10
+ export type ChangedAgainst = "latest" | "next" | "previous"
11
+
12
+ export type ChangedOptions = {
13
+ json?: boolean
14
+ list?: boolean
15
+ matrix?: boolean
16
+ ref?: string
17
+ against?: ChangedAgainst
18
+ includeUncommitted?: boolean
19
+ }
20
+
21
+ type GameStatus = "changed" | "unchanged" | "new"
22
+
23
+ type ChangedEntry = {
24
+ slug: string
25
+ displayName: string
26
+ teamID: string
27
+ dir: string
28
+ baseSha: string | null
29
+ ref: string
30
+ status: GameStatus
31
+ changedFiles: number
32
+ }
33
+
34
+ type EvalResult = { entry: ChangedEntry } | { error: string }
35
+
36
+ /** Reports which discovered games changed since their last deployed build, scoped per game folder. */
37
+ export const changed = async (dir: string, options: ChangedOptions = {}) => {
38
+ const { json = false, list = false, matrix = false, against = "latest", includeUncommitted = false } = options
39
+
40
+ if (getTokens().length === 0) {
41
+ console.error("Not logged in. Run `puzzmo login <token>` or set PUZZMO_TOKEN.")
42
+ process.exit(1)
43
+ }
44
+
45
+ const rootDir = path.resolve(dir)
46
+ if (!fs.existsSync(rootDir)) {
47
+ console.error(`Directory not found: ${dir}`)
48
+ process.exit(1)
49
+ }
50
+
51
+ const repoRoot = getRepoRoot(rootDir)
52
+ if (!repoRoot) {
53
+ console.error(`Not a git repository: ${rootDir}`)
54
+ process.exit(1)
55
+ }
56
+
57
+ const ref = options.ref || "HEAD"
58
+ const refSha = revParseShort(ref, repoRoot)
59
+ if (!refSha) {
60
+ console.error(`Could not resolve git ref: ${ref}`)
61
+ process.exit(1)
62
+ }
63
+
64
+ const { games, errors: discoveryErrors } = await discoverGames(rootDir, { requireDist: false })
65
+ if (!games.length && !discoveryErrors.length) {
66
+ console.error(`No puzzmo.json files found under ${rootDir}`)
67
+ process.exit(1)
68
+ }
69
+
70
+ const hardErrors = discoveryErrors.map((err) => `${err.slug ?? path.relative(rootDir, err.puzzmoJsonPath)}: ${err.errors.join("; ")}`)
71
+
72
+ const teamVersions = new Map<string, Promise<Map<string, GameVersions>>>()
73
+ const results = await Promise.all(
74
+ games.map((game) => evaluateGame(game, { repoRoot, ref, refSha, against, includeUncommitted, teamVersions })),
75
+ )
76
+ const report: ChangedEntry[] = []
77
+ for (const result of results) {
78
+ if ("entry" in result) report.push(result.entry)
79
+ else hardErrors.push(result.error)
80
+ }
81
+
82
+ const buildable = report.filter((entry) => entry.status !== "unchanged")
83
+
84
+ if (json) console.log(JSON.stringify(report, null, 2))
85
+ else if (matrix) console.log(JSON.stringify({ include: buildable.map((entry) => ({ dir: entry.dir, slug: entry.slug })) }))
86
+ else if (list) for (const entry of buildable) console.log(entry.dir)
87
+ else if (report.length) printTable(report)
88
+ else if (!hardErrors.length) console.log(`No puzzmo.json files found under ${rootDir}`)
89
+
90
+ if (hardErrors.length) {
91
+ console.error(`\n${hardErrors.length} error${hardErrors.length === 1 ? "" : "s"}:`)
92
+ for (const message of hardErrors) console.error(` ${message}`)
93
+ process.exit(1)
94
+ }
95
+ }
96
+
97
+ type EvalContext = {
98
+ repoRoot: string
99
+ ref: string
100
+ refSha: string
101
+ against: ChangedAgainst
102
+ includeUncommitted: boolean
103
+ // Memoizes the per-team runtime fetch so games sharing a team only hit the API once.
104
+ teamVersions: Map<string, Promise<Map<string, GameVersions>>>
105
+ }
106
+
107
+ /** Resolves one game's deployed SHA and diffs it against the ref. Returns either a report entry or a hard error. */
108
+ const evaluateGame = async (game: DiscoveredGame, ctx: EvalContext): Promise<EvalResult> => {
109
+ const { slug, teamID } = game.puzzmoFile.game
110
+ const dir = toPosix(path.relative(ctx.repoRoot, game.puzzmoJsonDir)) || "."
111
+
112
+ const credential = await resolveServerForTeam(teamID)
113
+ if (!credential) {
114
+ const matches = findTokensForTeam(teamID)
115
+ const message =
116
+ matches.length === 0
117
+ ? `No saved token for team ${teamID}. Run \`puzzmo login <token>\`.`
118
+ : `Token for team ${teamID} is registered against ${matches.map((m) => m.source).join(", ")} but none of those servers are reachable.`
119
+ return { error: `${slug}: ${message}` }
120
+ }
121
+
122
+ let versions: GameVersions | undefined
123
+ try {
124
+ versions = (await getTeamVersions(teamID, credential, ctx)).get(slug)
125
+ } catch (e) {
126
+ return { error: `${slug}: ${e instanceof Error ? e.message : String(e)}` }
127
+ }
128
+
129
+ const baseSha = pickBaseSha(versions ?? null, ctx.against)
130
+
131
+ // Never deployed (or the chosen slot is empty) — nothing to diff against, so it's new and buildable.
132
+ if (!baseSha) return { entry: makeEntry(game, dir, null, ctx.refSha, "new", 0) }
133
+
134
+ // The deployed commit has to exist locally or we can't diff — fail loudly rather than guessing.
135
+ if (!commitExists(baseSha, ctx.repoRoot))
136
+ return {
137
+ error: `${slug}: deployed commit ${baseSha} not found locally. Fetch full git history (e.g. actions/checkout fetch-depth: 0).`,
138
+ }
139
+
140
+ const changedFiles = countChanges(baseSha, ctx.ref, game.puzzmoJsonDir, ctx.includeUncommitted)
141
+ return { entry: makeEntry(game, dir, baseSha, ctx.refSha, changedFiles > 0 ? "changed" : "unchanged", changedFiles) }
142
+ }
143
+
144
+ const makeEntry = (
145
+ game: DiscoveredGame,
146
+ dir: string,
147
+ baseSha: string | null,
148
+ ref: string,
149
+ status: GameStatus,
150
+ changedFiles: number,
151
+ ): ChangedEntry => ({
152
+ slug: game.puzzmoFile.game.slug,
153
+ displayName: game.puzzmoFile.game.displayName,
154
+ teamID: game.puzzmoFile.game.teamID,
155
+ dir,
156
+ baseSha,
157
+ ref,
158
+ status,
159
+ changedFiles,
160
+ })
161
+
162
+ /** Fetches a team's per-game runtime SHAs once, memoizing the in-flight promise so concurrent games share the call. */
163
+ const getTeamVersions = (
164
+ teamID: string,
165
+ credential: { source: string; token: string },
166
+ ctx: EvalContext,
167
+ ): Promise<Map<string, GameVersions>> => {
168
+ let pending = ctx.teamVersions.get(teamID)
169
+ if (!pending) {
170
+ pending = fetchTeamGameVersions(sourceToURL(credential.source), credential.token)
171
+ ctx.teamVersions.set(teamID, pending)
172
+ }
173
+ return pending
174
+ }
175
+
176
+ /** Picks the baseline SHA for the chosen slot. `latest` is the most recent build the team has — the staged next, else the live current. */
177
+ const pickBaseSha = (versions: GameVersions | null, against: ChangedAgainst): string | null => {
178
+ if (!versions) return null
179
+ if (against === "next") return versions.next
180
+ if (against === "previous") return versions.previous
181
+ return versions.next ?? versions.current
182
+ }
183
+
184
+ /** Counts files differing under `dir` between `base` and `ref`, optionally folding in uncommitted working-tree changes. */
185
+ const countChanges = (base: string, ref: string, dir: string, includeUncommitted: boolean): number => {
186
+ const files = new Set(gitLines(["diff", "--name-only", `${base}..${ref}`, "--", "."], dir))
187
+ if (includeUncommitted) {
188
+ for (const file of gitLines(["diff", "--name-only", "--", "."], dir)) files.add(file) // unstaged
189
+ for (const file of gitLines(["diff", "--name-only", "--cached", "--", "."], dir)) files.add(file) // staged
190
+ for (const file of gitLines(["ls-files", "--others", "--exclude-standard", "--", "."], dir)) files.add(file) // untracked
191
+ }
192
+ return files.size
193
+ }
194
+
195
+ /** Prints the human-readable summary table. */
196
+ const printTable = (report: ChangedEntry[]) => {
197
+ const headers = ["GAME", "STATUS", "BASE", "FILES"]
198
+ const rows = report.map((entry) => [entry.slug, entry.status, entry.baseSha ?? "—", String(entry.changedFiles)])
199
+ const widths = headers.map((header, i) => Math.max(header.length, ...rows.map((row) => row[i].length)))
200
+ const format = (cols: string[]) => cols.map((col, i) => col.padEnd(widths[i])).join(" ")
201
+
202
+ console.log(format(headers))
203
+ for (const row of rows) console.log(format(row))
204
+
205
+ const changedCount = report.filter((entry) => entry.status !== "unchanged").length
206
+ console.log(`\n${changedCount} of ${report.length} game${report.length === 1 ? "" : "s"} changed.`)
207
+ }
208
+
209
+ const getRepoRoot = (cwd: string): string | null => tryGit(["rev-parse", "--show-toplevel"], cwd)
210
+
211
+ const revParseShort = (ref: string, cwd: string): string | null => tryGit(["rev-parse", "--short", ref], cwd)
212
+
213
+ /** True if `sha` resolves to a commit object in the repo at `cwd`. */
214
+ const commitExists = (sha: string, cwd: string): boolean => {
215
+ try {
216
+ execFileSync("git", ["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], { cwd, stdio: "ignore" })
217
+ return true
218
+ } catch {
219
+ return false
220
+ }
221
+ }
222
+
223
+ /** Runs a git command and returns its trimmed stdout, or null if it failed (git's own stderr is suppressed). */
224
+ const tryGit = (args: string[], cwd: string): string | null => {
225
+ try {
226
+ return execFileSync("git", args, { encoding: "utf-8", cwd, stdio: ["ignore", "pipe", "ignore"] }).trim()
227
+ } catch {
228
+ return null
229
+ }
230
+ }
231
+
232
+ /** Runs a git command that emits one file path per line and returns the non-empty lines, or [] if it failed. */
233
+ const gitLines = (args: string[], cwd: string): string[] => {
234
+ const out = tryGit(args, cwd)
235
+ if (out === null) return []
236
+ return out.split("\n").filter(Boolean)
237
+ }
238
+
239
+ const toPosix = (p: string): string => p.split(path.sep).join("/")
@@ -164,6 +164,8 @@ const uploadOneGame = async (game: DiscoveredGame, opts: UploadOneOptions): Prom
164
164
  for (const file of files) totalBytes += fs.statSync(file).size
165
165
  console.log(` ${plural(files.length, "file")}, ${formatBytes(totalBytes)} (sha ${sha.slice(0, 8)})`)
166
166
 
167
+ warnAboutAbsoluteScriptURLs(distDir)
168
+
167
169
  const result = await uploadFiles(
168
170
  apiURL,
169
171
  credential.token,
@@ -377,6 +379,34 @@ const maybeCreateMissingGame = async (
377
379
  }
378
380
  }
379
381
 
382
+ /**
383
+ * Warns when dist/index.html loads its JS via absolute URLs. Games are served from a per-version subpath
384
+ * (e.g. puzzmousercontent.com/assets/<hash>/...), so a "/assets/foo.js" src resolves to the host root and 404s.
385
+ * The fix is a relative base — e.g. `base: "./"` in vite.config.ts — so srcs become "./assets/foo.js".
386
+ */
387
+ const warnAboutAbsoluteScriptURLs = (distDir: string): void => {
388
+ const indexPath = path.join(distDir, "index.html")
389
+ if (!fs.existsSync(indexPath)) return
390
+
391
+ const html = fs.readFileSync(indexPath, "utf-8")
392
+ const offenders: string[] = []
393
+ const scriptRe = /<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi
394
+ let match: RegExpExecArray | null
395
+ while ((match = scriptRe.exec(html)) !== null) {
396
+ if (isAbsoluteAssetURL(match[1])) offenders.push(match[1])
397
+ }
398
+ if (!offenders.length) return
399
+
400
+ console.log(yellow(` Warning: index.html loads JS via absolute URLs, which will break as game is served from a subpath:`))
401
+ for (const url of offenders) console.log(yellow(` ${url}`))
402
+ console.log(
403
+ yellow(` Use relative paths instead — set \`base: "./"\` in your vite.config.ts (or your bundler's equivalent) and rebuild.`),
404
+ )
405
+ }
406
+
407
+ /** True for URLs that resolve outside the game's own folder: site-root ("/x"), protocol-relative ("//x"), or fully-qualified ("https://x"). */
408
+ const isAbsoluteAssetURL = (url: string): boolean => url.startsWith("/") || /^[a-z][a-z0-9+.-]*:\/\//i.test(url)
409
+
380
410
  /** Formats a byte count as a human-readable string */
381
411
  const formatBytes = (bytes: number): string => {
382
412
  if (bytes < 1024) return `${bytes} B`
@@ -3,7 +3,7 @@ import path from "node:path"
3
3
 
4
4
  import { discoverGames } from "../util/discoverGames.js"
5
5
 
6
- /** CLI command: puzzmo validate [dir] — discovers every puzzmo.json under dir and validates each */
6
+ /** CLI command: puzzmo games validate [dir] — discovers every puzzmo.json under dir and validates each */
7
7
  export const validate = async (dir: string) => {
8
8
  const rootDir = path.resolve(dir)
9
9
  if (!fs.existsSync(rootDir)) {
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { defineCommand, runMain } from "citty"
4
4
 
5
5
  import { agentTest } from "./commands/agent-test.js"
6
+ import { type ChangedAgainst, changed } from "./commands/changed.js"
6
7
  import { gameCreate } from "./commands/game/create.js"
7
8
  import { login } from "./commands/login.js"
8
9
  import { migrate } from "./commands/migrate.js"
@@ -46,6 +47,38 @@ const validateCommand = defineCommand({
46
47
  run: ({ args }) => validate(args.dir),
47
48
  })
48
49
 
50
+ const changedCommand = defineCommand({
51
+ meta: { name: "changed", description: "Report which games changed since their last deployed build (for CI)." },
52
+ args: {
53
+ dir: { type: "positional", description: "Directory to scan", required: false, default: "." },
54
+ json: { type: "boolean", description: "Output a JSON report for every discovered game" },
55
+ list: { type: "boolean", description: "Output only the changed game folders, one per line" },
56
+ matrix: { type: "boolean", description: "Output a GitHub Actions matrix of the changed games" },
57
+ ref: { type: "string", description: "Git ref to compare against (default HEAD)" },
58
+ against: {
59
+ type: "enum",
60
+ options: ["latest", "next", "previous"],
61
+ description: "Which deployed build to use as the baseline (default latest)",
62
+ },
63
+ "include-uncommitted": { type: "boolean", description: "Also count uncommitted working-tree changes" },
64
+ },
65
+ run: ({ args }) =>
66
+ changed(args.dir, {
67
+ json: args.json,
68
+ list: args.list,
69
+ matrix: args.matrix,
70
+ ref: args.ref,
71
+ against: args.against as ChangedAgainst | undefined,
72
+ includeUncommitted: args["include-uncommitted"],
73
+ }),
74
+ })
75
+
76
+ // Commands that operate across every puzzmo.json in a repo live under `puzzmo games`.
77
+ const gamesCommand = defineCommand({
78
+ meta: { name: "games", description: "Commands that run across all the games in your repo" },
79
+ subCommands: { changed: changedCommand, upload: uploadCommand, validate: validateCommand },
80
+ })
81
+
49
82
  const migrateCommand = defineCommand({
50
83
  meta: { name: "migrate", description: "List and select migration skills from dev.puzzmo.com" },
51
84
  run: () => migrate(),
@@ -83,14 +116,37 @@ const gameCommand = defineCommand({
83
116
  subCommands: { create: gameCreateCommand },
84
117
  })
85
118
 
119
+ /** Warn that a top-level command moved under `puzzmo games`, then run it anyway. */
120
+ const movedToGames = (name: string) => console.warn(`"puzzmo ${name}" is now "puzzmo games ${name}". The old form still works for now.`)
121
+
122
+ // Deprecated top-level aliases, kept hidden so existing scripts (e.g. `puzzmo upload`) keep working.
123
+ const uploadAlias = defineCommand({
124
+ meta: { ...uploadCommand.meta, hidden: true },
125
+ args: uploadCommand.args,
126
+ run: ({ args }) => {
127
+ movedToGames("upload")
128
+ return upload(args.dir, { verbose: args.verbose })
129
+ },
130
+ })
131
+
132
+ const validateAlias = defineCommand({
133
+ meta: { ...validateCommand.meta, hidden: true },
134
+ args: validateCommand.args,
135
+ run: ({ args }) => {
136
+ movedToGames("validate")
137
+ return validate(args.dir)
138
+ },
139
+ })
140
+
86
141
  const main = defineCommand({
87
142
  meta: { name: "puzzmo", description: "Puzzmo CLI" },
88
143
  subCommands: {
89
144
  login: loginCommand,
90
- upload: uploadCommand,
91
- validate: validateCommand,
145
+ games: gamesCommand,
92
146
  migrate: migrateCommand,
93
147
  game: gameCommand,
148
+ upload: uploadAlias,
149
+ validate: validateAlias,
94
150
  "agent-test": defineCommand({
95
151
  meta: { name: "agent-test", description: "Run the agent test harness", hidden: true },
96
152
  run: () => agentTest(),