@puzzmo/cli 1.0.40 → 1.0.42

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/src/index.ts CHANGED
@@ -1,76 +1,93 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { defineCommand, runMain } from "citty"
4
+
3
5
  import { agentTest } from "./commands/agent-test.js"
6
+ import { gameCreate } from "./commands/game/create.js"
4
7
  import { login } from "./commands/login.js"
8
+ import { migrate } from "./commands/migrate.js"
5
9
  import { upload } from "./commands/upload.js"
6
10
  import { validate } from "./commands/validate.js"
7
- import { gameCreate } from "./commands/game/create.js"
8
- import { migrate } from "./commands/migrate.js"
11
+ import { defaultSource } from "./util/config.js"
12
+
13
+ const loginCommand = defineCommand({
14
+ meta: {
15
+ name: "login",
16
+ description: "Save a CLI auth token. Multiple tokens can be stored, one per --source server.",
17
+ },
18
+ args: {
19
+ token: { type: "positional", description: "The pzt- token from dev.puzzmo.com", required: true },
20
+ source: { type: "string", description: "Server this token belongs to", default: defaultSource },
21
+ },
22
+ run: ({ args }) => login(args.token, args.source),
23
+ })
9
24
 
10
- const [command, ...args] = process.argv.slice(2)
25
+ const uploadCommand = defineCommand({
26
+ meta: {
27
+ name: "upload",
28
+ description: "Discover puzzmo.json files and upload each game's build and sync the puzzmo.json.",
29
+ },
30
+ args: {
31
+ dir: { type: "positional", description: "Directory to scan", required: false, default: "." },
32
+ verbose: { type: "boolean", description: "Print request URLs and full error bodies", alias: "v" },
33
+ },
34
+ run: ({ args }) => upload(args.dir, { verbose: args.verbose }),
35
+ })
11
36
 
12
- const printUsage = () => {
13
- console.log(`Usage:
14
- puzzmo login <token> Save a CLI auth token
15
- puzzmo game create [token] Create a new Puzzmo game project
37
+ const validateCommand = defineCommand({
38
+ meta: { name: "validate", description: "Validate every puzzmo.json under [dir]" },
39
+ args: {
40
+ dir: { type: "positional", description: "Directory to scan", required: false, default: "." },
41
+ },
42
+ run: ({ args }) => validate(args.dir),
43
+ })
16
44
 
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
- puzzmo migrate List and select migration skills from dev.puzzmo.com`)
20
- }
45
+ const migrateCommand = defineCommand({
46
+ meta: { name: "migrate", description: "List and select migration skills from dev.puzzmo.com" },
47
+ run: () => migrate(),
48
+ })
21
49
 
22
- const run = async () => {
23
- switch (command) {
24
- case "login": {
25
- const token = args[0]
26
- if (!token) {
27
- console.error("Usage: puzzmo login <token>")
28
- process.exit(1)
29
- }
30
- login(token)
31
- break
32
- }
33
- case "upload": {
34
- const verbose = args.includes("--verbose") || args.includes("-v")
35
- const dir = args.find((a) => !a.startsWith("-")) ?? "."
36
- await upload(dir, { verbose })
37
- break
38
- }
39
- case "game": {
40
- const [subcommand, ...subArgs] = args
41
- if (subcommand === "create") {
42
- await gameCreate(subArgs)
43
- } else {
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
- )
47
- process.exit(1)
48
- }
49
- break
50
- }
51
- case "validate": {
52
- validate(args[0] || ".")
53
- break
54
- }
55
- case "migrate": {
56
- await migrate()
57
- break
58
- }
59
- case "agent-test": {
60
- await agentTest()
61
- break
62
- }
63
- default:
64
- printUsage()
65
- break
66
- }
67
- }
50
+ const gameCreateCommand = defineCommand({
51
+ meta: { name: "create", description: "Scaffold a new Puzzmo game project" },
52
+ args: {
53
+ strategy: { type: "enum", options: ["import", "blank", "prompt"], description: "How to seed the new game" },
54
+ prompt: { type: "string", description: "Game description (used with --strategy prompt)" },
55
+ name: { type: "string", description: "Game display name" },
56
+ url: { type: "string", description: "Source URL (used with --strategy import)" },
57
+ agent: { type: "string", description: "LLM agent id to drive scaffolding" },
58
+ token: { type: "string", description: "Save this token before creating the game" },
59
+ pm: { type: "enum", options: ["npm", "yarn", "pnpm"], description: "Package manager to use" },
60
+ },
61
+ run: ({ args }) =>
62
+ gameCreate({
63
+ strategy: args.strategy,
64
+ prompt: args.prompt,
65
+ name: args.name,
66
+ url: args.url,
67
+ agent: args.agent,
68
+ accessToken: args.token,
69
+ pm: args.pm,
70
+ }),
71
+ })
68
72
 
69
- run().catch((err) => {
70
- console.error(err.message || err)
71
- if (err && err.cause) {
72
- const cause = err.cause
73
- console.error(`Caused by: ${cause instanceof Error ? cause.message : cause}`)
74
- }
75
- process.exit(1)
73
+ const gameCommand = defineCommand({
74
+ meta: { name: "game", description: "Game project commands" },
75
+ subCommands: { create: gameCreateCommand },
76
76
  })
77
+
78
+ const main = defineCommand({
79
+ meta: { name: "puzzmo", description: "Puzzmo CLI" },
80
+ subCommands: {
81
+ login: loginCommand,
82
+ upload: uploadCommand,
83
+ validate: validateCommand,
84
+ migrate: migrateCommand,
85
+ game: gameCommand,
86
+ "agent-test": defineCommand({
87
+ meta: { name: "agent-test", description: "Run the agent test harness", hidden: true },
88
+ run: () => agentTest(),
89
+ }),
90
+ },
91
+ })
92
+
93
+ runMain(main)
package/src/util/api.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  import fs from "node:fs"
2
2
  import path from "node:path"
3
3
 
4
- import { getAPIURL } from "./config.js"
5
-
6
4
  /** The schema for a puzzmo.json file - mirrors PuzzmoFile from @puzzmo-com/shared/hostAPI */
7
5
  export type PuzzmoFile = {
8
6
  game: {
@@ -132,6 +130,7 @@ const formatBytes = (bytes: number): string => {
132
130
 
133
131
  /** Multi-step upload: init -> batched file uploads -> complete */
134
132
  export const uploadFiles = async (
133
+ apiURL: string,
135
134
  token: string,
136
135
  gameSlug: string,
137
136
  sha: string,
@@ -142,7 +141,6 @@ export const uploadFiles = async (
142
141
  options: UploadFilesOptions = {},
143
142
  ): Promise<CompleteResponse> => {
144
143
  const { verbose = false, description, repoURL } = options
145
- const apiURL = getAPIURL()
146
144
  if (verbose) console.log(` API URL: ${apiURL}`)
147
145
 
148
146
  // Step 1: Init session (includes puzzmo.json metadata)
@@ -5,33 +5,137 @@ import path from "node:path"
5
5
  const configDir = path.join(os.homedir(), ".puzzmo")
6
6
  const configPath = path.join(configDir, "config.json")
7
7
 
8
- type Config = {
9
- token?: string
10
- apiURL?: string
8
+ export const defaultSource = "https://api.puzzmo.com"
9
+
10
+ export type TokenEntry = {
11
+ /** Server identifier (e.g. "api.puzzmo.com" or "localhost:8911") */
12
+ source: string
13
+ /** The pzt- prefixed JWT issued by that server */
14
+ token: string
11
15
  }
12
16
 
13
- /** Reads the CLI config from ~/.puzzmo/config.json */
17
+ type Config = { tokens: TokenEntry[] }
18
+
19
+ /** Reads ~/.puzzmo/config.json. Returns an empty token list if the file doesn't exist. */
14
20
  export const readConfig = (): Config => {
15
21
  try {
16
- const raw = fs.readFileSync(configPath, "utf-8")
17
- return JSON.parse(raw)
22
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")) as Partial<Config>
23
+ return { tokens: parsed.tokens ?? [] }
18
24
  } catch {
19
- return {}
25
+ return { tokens: [] }
20
26
  }
21
27
  }
22
28
 
23
- /** Writes the CLI config to ~/.puzzmo/config.json */
24
- export const writeConfig = (config: Config) => {
29
+ const writeConfig = (config: Config) => {
25
30
  fs.mkdirSync(configDir, { recursive: true })
26
31
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
27
32
  }
28
33
 
29
- /** Returns the auth token from config or PUZZMO_TOKEN env var */
30
- export const getToken = (): string | undefined => {
31
- return process.env.PUZZMO_TOKEN || readConfig().token
34
+ /** Saves a token for `source`, replacing any existing entry for that same source */
35
+ export const addToken = (source: string, token: string) => {
36
+ const normalized = normalizeSource(source)
37
+ const { tokens } = readConfig()
38
+ const remaining = tokens.filter((t) => normalizeSource(t.source) !== normalized)
39
+ remaining.push({ source: normalized, token })
40
+ writeConfig({ tokens: remaining })
41
+ }
42
+
43
+ /** Removes any saved token for `source`. Returns true if something was removed. */
44
+ export const removeToken = (source: string): boolean => {
45
+ const normalized = normalizeSource(source)
46
+ const { tokens } = readConfig()
47
+ const remaining = tokens.filter((t) => normalizeSource(t.source) !== normalized)
48
+ if (remaining.length === tokens.length) return false
49
+ writeConfig({ tokens: remaining })
50
+ return true
32
51
  }
33
52
 
34
- /** Returns the API base URL */
35
- export const getAPIURL = (): string => {
36
- return process.env.PUZZMO_API_URL || readConfig().apiURL || "https://api.puzzmo.com"
53
+ /** Returns saved tokens. PUZZMO_TOKEN env var (with optional PUZZMO_API_URL) overrides everything. */
54
+ export const getTokens = (): TokenEntry[] => {
55
+ const envToken = process.env.PUZZMO_TOKEN
56
+ if (envToken) {
57
+ const source = normalizeSource(process.env.PUZZMO_API_URL ?? defaultSource)
58
+ return [{ source, token: envToken }]
59
+ }
60
+ return readConfig().tokens
61
+ }
62
+
63
+ /** Best-guess token for clients that don't care which server it points at (e.g. .mcp.json scaffolding) */
64
+ export const getDefaultToken = (): string | undefined => getTokens()[0]?.token
65
+
66
+ type TokenPayload = { teamID?: string; createdByID?: string; iat?: number }
67
+
68
+ /** Decodes a `pzt-<jwt>` token (without verifying) and returns its payload, or null on failure */
69
+ export const decodeTokenPayload = (token: string): TokenPayload | null => {
70
+ try {
71
+ const stripped = token.startsWith("pzt-") ? token.slice(4) : token
72
+ const parts = stripped.split(".")
73
+ if (parts.length < 2) return null
74
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/")
75
+ const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4)
76
+ return JSON.parse(Buffer.from(padded, "base64").toString("utf-8")) as TokenPayload
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ /** Strips protocol and trailing slashes so two spellings of the same server compare equal */
83
+ export const normalizeSource = (source: string): string =>
84
+ source
85
+ .trim()
86
+ .replace(/^https?:\/\//, "")
87
+ .replace(/\/+$/, "")
88
+
89
+ /** Re-attaches a protocol to a normalized source. Localhost defaults to http, everything else to https. */
90
+ export const sourceToURL = (source: string): string => {
91
+ const trimmed = source.trim().replace(/\/+$/, "")
92
+ if (/^https?:\/\//.test(trimmed)) return trimmed
93
+ const normalized = normalizeSource(trimmed)
94
+ if (isLocalhost(normalized)) return `http://${normalized}`
95
+ return `https://${normalized}`
96
+ }
97
+
98
+ const isLocalhost = (source: string): boolean => {
99
+ const n = normalizeSource(source)
100
+ return n.startsWith("localhost") || n.startsWith("127.0.0.1") || n.startsWith("0.0.0.0")
101
+ }
102
+
103
+ const isPuzzmoCom = (source: string): boolean => /(^|\.)puzzmo\.com(:|\/|$)/.test(normalizeSource(source))
104
+
105
+ /** Sorts saved tokens by upload preference: localhost > *.puzzmo.com > anything else */
106
+ export const sortByServerPriority = (tokens: TokenEntry[]): TokenEntry[] => {
107
+ const priority = (t: TokenEntry) => (isLocalhost(t.source) ? 0 : isPuzzmoCom(t.source) ? 1 : 2)
108
+ return [...tokens].sort((a, b) => priority(a) - priority(b))
109
+ }
110
+
111
+ /** Returns saved tokens whose JWT teamID matches the given teamID */
112
+ export const findTokensForTeam = (teamID: string): TokenEntry[] => getTokens().filter((t) => decodeTokenPayload(t.token)?.teamID === teamID)
113
+
114
+ /** True if a quick fetch to `${source}/graphql` returns any HTTP response within `timeoutMs` */
115
+ export const isServerReachable = async (source: string, timeoutMs = 1500): Promise<boolean> => {
116
+ const url = `${sourceToURL(source)}/graphql`
117
+ const controller = new AbortController()
118
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
119
+ try {
120
+ await fetch(url, { method: "GET", signal: controller.signal })
121
+ return true
122
+ } catch {
123
+ return false
124
+ } finally {
125
+ clearTimeout(timer)
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Picks the (source, token) pair to use for `teamID`. Prefers localhost when reachable,
131
+ * then *.puzzmo.com, then any other matching source. Returns null if no token matches.
132
+ */
133
+ export const resolveServerForTeam = async (teamID: string): Promise<TokenEntry | null> => {
134
+ const matches = sortByServerPriority(findTokensForTeam(teamID))
135
+ if (matches.length === 0) return null
136
+ for (const candidate of matches) {
137
+ if (isLocalhost(candidate.source) && !(await isServerReachable(candidate.source))) continue
138
+ return candidate
139
+ }
140
+ return null
37
141
  }
@@ -1,5 +1,3 @@
1
- import { getAPIURL } from "./config.js"
2
-
3
1
  const createUserGameMutation = `
4
2
  mutation cliCreateUserGameMutation($teamAccessToken: String!, $input: CreateUserGameInput!) {
5
3
  createUserGame(teamAccessToken: $teamAccessToken, input: $input) {
@@ -22,6 +20,8 @@ type CreateUserGameResponse = {
22
20
  }
23
21
 
24
22
  export type CreateUserGameOptions = {
23
+ /** Base URL of the API server to call (e.g. "https://api.puzzmo.com") */
24
+ apiURL: string
25
25
  /** A teamAccessToken (the same token saved by `puzzmo login`). The mutation uses it to identify the team. */
26
26
  teamAccessToken: string
27
27
  displayName: string
@@ -31,7 +31,7 @@ export type CreatedGame = { id: string; slug: string; displayName: string; newAc
31
31
 
32
32
  /** Calls the createUserGame mutation using a teamAccessToken for auth. */
33
33
  export const createUserGame = async (options: CreateUserGameOptions): Promise<CreatedGame> => {
34
- const url = `${getAPIURL()}/graphql`
34
+ const url = `${options.apiURL}/graphql`
35
35
 
36
36
  const response = await fetch(url, {
37
37
  method: "POST",
@@ -2,6 +2,7 @@ import { defineConfig } from "vite"
2
2
  import { puzzmoSimulator, appBundlePlugin } from "@puzzmo/sdk/vite"
3
3
 
4
4
  export default defineConfig({
5
+ base: "./",
5
6
  plugins: [puzzmoSimulator({ fixturesGlob: "/fixtures/puzzles/**/*.json" }), appBundlePlugin()],
6
7
  build: {
7
8
  outDir: "dist",