@fzgame/cli 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,182 @@
1
+ export type FzGameEntry = {
2
+ game: string
3
+ editor: string
4
+ handler: string
5
+ }
6
+
7
+ export type FzGameInfo = {
8
+ name: string
9
+ slug: string
10
+ version: number
11
+ image?: string
12
+ description?: string
13
+ author?: string
14
+ gitUrl?: string
15
+ }
16
+
17
+ export type FzGameManifest = {
18
+ info: FzGameInfo
19
+ entry: FzGameEntry
20
+ formSchema?: Record<string, unknown>
21
+ formUiSchema?: Record<string, unknown>
22
+ resultSchema?: Record<string, unknown>
23
+ snapshotSchema?: Record<string, unknown>
24
+ gameDataSchema?: Record<string, unknown>
25
+ name: string
26
+ slug: string
27
+ version: number
28
+ description?: string
29
+ configSchema?: Record<string, unknown>
30
+ }
31
+
32
+ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
33
+
34
+ export const DEFAULT_ENTRY: FzGameEntry = {
35
+ game: 'game/index.html',
36
+ editor: 'editor/index.html',
37
+ handler: 'handler/index.js',
38
+ }
39
+
40
+ export function readFormSchema(
41
+ schema: Record<string, unknown> | null | undefined,
42
+ ): Record<string, unknown> {
43
+ if (!schema) return {}
44
+ const form = schema.formSchema
45
+ if (form && typeof form === 'object' && !Array.isArray(form)) {
46
+ return form as Record<string, unknown>
47
+ }
48
+ return schema
49
+ }
50
+
51
+ export function toStoredConfigSchema(
52
+ manifest: Pick<
53
+ FzGameManifest,
54
+ | 'formSchema'
55
+ | 'formUiSchema'
56
+ | 'resultSchema'
57
+ | 'snapshotSchema'
58
+ | 'gameDataSchema'
59
+ | 'info'
60
+ >,
61
+ ): Record<string, unknown> {
62
+ return {
63
+ formSchema: manifest.formSchema ?? { type: 'object', properties: {} },
64
+ ...(manifest.formUiSchema ? { formUiSchema: manifest.formUiSchema } : {}),
65
+ ...(manifest.resultSchema ? { resultSchema: manifest.resultSchema } : {}),
66
+ ...(manifest.snapshotSchema ? { snapshotSchema: manifest.snapshotSchema } : {}),
67
+ ...(manifest.gameDataSchema ? { gameDataSchema: manifest.gameDataSchema } : {}),
68
+ ...(manifest.info.author ? { author: manifest.info.author } : {}),
69
+ ...(manifest.info.gitUrl ? { git_url: manifest.info.gitUrl } : {}),
70
+ ...(manifest.info.image ? { image: manifest.info.image } : {}),
71
+ }
72
+ }
73
+
74
+ export function parseFzGameManifest(input: unknown): FzGameManifest {
75
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
76
+ throw new Error('fzgame.json must be an object')
77
+ }
78
+ const raw = input as Record<string, unknown>
79
+ const info = parseInfo(raw)
80
+ const formSchema =
81
+ readObject(raw.formSchema, 'formSchema') ??
82
+ readObject(raw.configSchema, 'configSchema')
83
+ const manifest: FzGameManifest = {
84
+ info,
85
+ entry: parseEntry(raw.entry),
86
+ formSchema,
87
+ formUiSchema: readObject(raw.formUiSchema, 'formUiSchema'),
88
+ resultSchema: readObject(raw.resultSchema, 'resultSchema'),
89
+ snapshotSchema: readObject(raw.snapshotSchema, 'snapshotSchema'),
90
+ gameDataSchema: readObject(raw.gameDataSchema, 'gameDataSchema'),
91
+ name: info.name,
92
+ slug: info.slug,
93
+ version: info.version,
94
+ description: info.description,
95
+ }
96
+ manifest.configSchema = toStoredConfigSchema(manifest)
97
+ return manifest
98
+ }
99
+
100
+ function parseInfo(raw: Record<string, unknown>): FzGameInfo {
101
+ const nested =
102
+ raw.info && typeof raw.info === 'object' && !Array.isArray(raw.info)
103
+ ? (raw.info as Record<string, unknown>)
104
+ : {}
105
+ const name = pickString(nested.name, raw.name)
106
+ const slug = pickString(nested.slug, raw.slug)
107
+ if (!name || name.trim().length < 2) {
108
+ throw new Error('fzgame.json must include a name (min 2 characters)')
109
+ }
110
+ if (!slug || !SLUG_RE.test(slug)) {
111
+ throw new Error(
112
+ 'fzgame.json slug must be kebab-case (lowercase letters, digits, hyphens)',
113
+ )
114
+ }
115
+ const versionRaw = nested.version ?? raw.version
116
+ const version =
117
+ versionRaw === undefined || versionRaw === null ? 1 : versionRaw
118
+ if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
119
+ throw new Error('fzgame.json version must be a positive integer')
120
+ }
121
+ const gitUrl = optionalString(nested.git_url ?? nested.gitUrl)
122
+ if (gitUrl && !/^https?:\/\//i.test(gitUrl)) {
123
+ throw new Error('fzgame.json info.git_url must be an http(s) URL')
124
+ }
125
+ const image = optionalString(nested.image)
126
+ const description = optionalString(nested.description ?? raw.description)
127
+ const author = optionalString(nested.author)
128
+ return {
129
+ name: name.trim(),
130
+ slug,
131
+ version,
132
+ ...(image ? { image } : {}),
133
+ ...(description ? { description } : {}),
134
+ ...(author ? { author } : {}),
135
+ ...(gitUrl ? { gitUrl } : {}),
136
+ }
137
+ }
138
+
139
+ function parseEntry(input: unknown): FzGameEntry {
140
+ if (input === undefined || input === null) return { ...DEFAULT_ENTRY }
141
+ if (typeof input !== 'object' || Array.isArray(input)) {
142
+ throw new Error('fzgame.json entry must be an object')
143
+ }
144
+ const raw = input as Record<string, unknown>
145
+ return {
146
+ game: readPath(raw.game, DEFAULT_ENTRY.game),
147
+ editor: readPath(raw.editor, DEFAULT_ENTRY.editor),
148
+ handler: readPath(raw.handler, DEFAULT_ENTRY.handler),
149
+ }
150
+ }
151
+
152
+ function readPath(value: unknown, fallback: string): string {
153
+ if (value === undefined || value === null) return fallback
154
+ if (typeof value !== 'string' || !value.trim()) {
155
+ throw new Error('fzgame.json entry paths must be relative')
156
+ }
157
+ const path = value.replace(/\\/g, '/').replace(/^\.\//, '')
158
+ if (path.startsWith('/') || path.includes('..')) {
159
+ throw new Error('fzgame.json entry paths must be relative without ..')
160
+ }
161
+ return path
162
+ }
163
+
164
+ function readObject(value: unknown, field: string): Record<string, unknown> | undefined {
165
+ if (value === undefined || value === null) return undefined
166
+ if (typeof value !== 'object' || Array.isArray(value)) {
167
+ throw new Error(`fzgame.json ${field} must be a JSON Schema object`)
168
+ }
169
+ return value as Record<string, unknown>
170
+ }
171
+
172
+ function pickString(...values: unknown[]): string | undefined {
173
+ for (const value of values) {
174
+ if (typeof value === 'string' && value.trim()) return value
175
+ }
176
+ return undefined
177
+ }
178
+
179
+ function optionalString(value: unknown): string | undefined {
180
+ if (value === undefined || value === null || value === '') return undefined
181
+ return String(value)
182
+ }
package/src/pack.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
2
+ import { dirname, join, relative } from 'node:path'
3
+ import { KIT_HINT, resolveKitFile } from './kit-path'
4
+ import { parseFzGameManifest, type FzGameManifest } from './manifest'
5
+ import { buildZip } from './zip'
6
+
7
+ export type PackOptions = {
8
+ /** Kit bundle to vendor. Defaults to the installed `@fzgame/kit`; `null` skips vendoring. */
9
+ kitFile?: string | null
10
+ }
11
+
12
+ export async function loadManifest(root: string): Promise<FzGameManifest> {
13
+ const manifestPath = join(root, 'fzgame.json')
14
+ let text: string
15
+ try {
16
+ text = await readFile(manifestPath, 'utf8')
17
+ } catch (error) {
18
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
19
+ throw new Error(`No fzgame.json in ${root}. Run fzgame dev from a game root.`)
20
+ }
21
+ throw error
22
+ }
23
+ try {
24
+ return parseFzGameManifest(JSON.parse(text))
25
+ } catch (error) {
26
+ throw new Error(`Invalid fzgame.json in ${root}: ${(error as Error).message}`)
27
+ }
28
+ }
29
+
30
+ export async function collectGameFiles(
31
+ root: string,
32
+ options: PackOptions = {},
33
+ ): Promise<Record<string, Buffer>> {
34
+ const manifest = await loadManifest(root)
35
+ const files: Record<string, Buffer> = {
36
+ 'fzgame.json': await readFile(join(root, 'fzgame.json')),
37
+ }
38
+
39
+ for (const dir of ['game', 'editor', 'handler']) {
40
+ const abs = join(root, dir)
41
+ if (!(await exists(abs))) {
42
+ throw new Error(`Missing required directory: ${dir}/`)
43
+ }
44
+ await walk(abs, async (file) => {
45
+ const rel = relative(root, file).replace(/\\/g, '/')
46
+ files[rel] = await readFile(file)
47
+ })
48
+ }
49
+
50
+ for (const path of [manifest.entry.game, manifest.entry.editor, manifest.entry.handler]) {
51
+ if (!files[path]) throw new Error(`Missing required entrypoint: ${path}`)
52
+ }
53
+
54
+ await vendorKit(files, options)
55
+ return files
56
+ }
57
+
58
+ export async function buildGame(root: string, outFile?: string, options: PackOptions = {}) {
59
+ const manifest = await loadManifest(root)
60
+ const files = await collectGameFiles(root, options)
61
+ const zip = buildZip(files)
62
+ const dest = outFile || join(root, 'dist', `${manifest.slug}-v${manifest.version}.zip`)
63
+ await mkdir(dirname(dest), { recursive: true })
64
+ await writeFile(dest, zip)
65
+ return { dest, manifest, files: Object.keys(files) }
66
+ }
67
+
68
+ async function vendorKit(files: Record<string, Buffer>, options: PackOptions) {
69
+ if (files['game/vendor/kit.js']) return
70
+ if (options.kitFile === null) return
71
+
72
+ let kitFile: string
73
+ if (options.kitFile) {
74
+ kitFile = options.kitFile
75
+ } else {
76
+ try {
77
+ kitFile = resolveKitFile()
78
+ } catch (error) {
79
+ throw new Error(
80
+ `Cannot vendor the game runtime: @fzgame/kit is not installed (${(error as Error).message}). ${KIT_HINT}`,
81
+ )
82
+ }
83
+ }
84
+
85
+ if (!(await exists(kitFile))) {
86
+ throw new Error(`Cannot vendor the game runtime: ${kitFile} does not exist. ${KIT_HINT}`)
87
+ }
88
+ files['game/vendor/kit.js'] = await readFile(kitFile)
89
+ }
90
+
91
+ async function walk(dir: string, visit: (file: string) => Promise<void>) {
92
+ const entries = await readdir(dir, { withFileTypes: true })
93
+ for (const entry of entries) {
94
+ if (entry.name === '.DS_Store') continue
95
+ const abs = join(dir, entry.name)
96
+ if (entry.isDirectory()) await walk(abs, visit)
97
+ else await visit(abs)
98
+ }
99
+ }
100
+
101
+ async function exists(path: string) {
102
+ try {
103
+ await stat(path)
104
+ return true
105
+ } catch {
106
+ return false
107
+ }
108
+ }
package/src/program.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { Command } from 'commander'
2
+ import { startDevServer } from './dev'
3
+ import { initGame } from './init'
4
+ import { buildGame } from './pack'
5
+ import { publishGame } from './publish'
6
+ import { startRunner } from './runner'
7
+ import { VERSION } from './version'
8
+
9
+ const DEFAULT_API = 'http://localhost:3020'
10
+
11
+ export function buildProgram() {
12
+ const program = new Command()
13
+
14
+ program
15
+ .name('fzgame')
16
+ .description('Build, publish, and develop FZ games')
17
+ .version(VERSION)
18
+ .addHelpText(
19
+ 'after',
20
+ `
21
+ Environment (a .env file in the working directory is read too, shell values win):
22
+ FZGAME_API GameHub API for publish and runner (default ${DEFAULT_API})
23
+ FZGAME_SERVER Alias for FZGAME_API
24
+ FZGAME_TOKEN Session token for publish
25
+ FZGAME_SPACE_ID Space id, publishes a private game
26
+ FZGAME_RUNNER_KEY Runner key from the BO receipt
27
+ FZGAME_RUNNER_PASS Runner pass from the BO receipt`,
28
+ )
29
+
30
+ program
31
+ .command('init [dir]')
32
+ .description('Scaffold a game from the bundled click-counter example')
33
+ .option('-f, --force', 'write into a directory that already has files')
34
+ .action(async (dir: string | undefined, opts: { force?: boolean }) => {
35
+ const result = await initGame(dir || '.', { force: opts.force })
36
+ console.log(`Scaffolded ${result.files.length} files in ${result.dir}`)
37
+ for (const file of result.files) console.log(` ${file}`)
38
+ console.log('Next: fzgame dev')
39
+ })
40
+
41
+ program
42
+ .command('build')
43
+ .description('Validate fzgame.json and pack game/editor/handler into a zip')
44
+ .option('--cwd <dir>', 'game root', process.cwd())
45
+ .option('-o, --out <file>', 'output zip path')
46
+ .action(async (opts: { cwd: string; out?: string }) => {
47
+ const result = await buildGame(opts.cwd, opts.out)
48
+ console.log(
49
+ `Built ${result.manifest.slug}@${result.manifest.version} -> ${result.dest}`,
50
+ )
51
+ })
52
+
53
+ program
54
+ .command('publish')
55
+ .description('Build and upload the zip to /games/publishing')
56
+ .option('--cwd <dir>', 'game root', process.cwd())
57
+ .option('--api <url>', 'GameHub API', process.env.FZGAME_API || DEFAULT_API)
58
+ .option('--token <token>', 'session token', process.env.FZGAME_TOKEN)
59
+ .option('--space <id>', 'space id for private games', process.env.FZGAME_SPACE_ID)
60
+ .action(async (opts: { cwd: string; api: string; token?: string; space?: string }) => {
61
+ if (!opts.token) {
62
+ throw new Error('Pass --token or set FZGAME_TOKEN')
63
+ }
64
+ const game = await publishGame({
65
+ cwd: opts.cwd,
66
+ api: opts.api,
67
+ token: opts.token,
68
+ spaceId: opts.space,
69
+ })
70
+ console.log(`Published ${game.slug}@${game.version} (${game.id})`)
71
+ if (game.entryPointUrl) console.log(game.entryPointUrl)
72
+ })
73
+
74
+ program
75
+ .command('dev')
76
+ .description('Serve the game locally with a mock runtime kit API')
77
+ .option('--cwd <dir>', 'game root', process.cwd())
78
+ .option('-p, --port <port>', 'port', '3400')
79
+ .option('--host <host>', 'bind address (use 0.0.0.0 to expose on the LAN)', '127.0.0.1')
80
+ .action(async (opts: { cwd: string; port: string; host: string }) => {
81
+ const { port, manifest } = await startDevServer(opts.cwd, Number(opts.port), {
82
+ host: opts.host,
83
+ })
84
+ const shown = opts.host === '0.0.0.0' || opts.host === '::' ? '127.0.0.1' : opts.host
85
+ console.log(`${manifest.name} dev server http://${shown}:${port}`)
86
+ if (shown !== opts.host) console.log(`Bound to ${opts.host}, reachable from the LAN`)
87
+ })
88
+
89
+ const runner = program.command('runner').description('Self-hosted GameHub CI runner')
90
+
91
+ runner
92
+ .command('start')
93
+ .description('Connect to the API and run fetch → build → test → publish jobs')
94
+ .option(
95
+ '--server <url>',
96
+ 'GameHub API',
97
+ process.env.FZGAME_API || process.env.FZGAME_SERVER || DEFAULT_API,
98
+ )
99
+ .option('--key <key>', 'runner key from the BO receipt', process.env.FZGAME_RUNNER_KEY)
100
+ .option('--pass <pass>', 'runner pass from the BO receipt', process.env.FZGAME_RUNNER_PASS)
101
+ .action(async (opts: { server: string; key?: string; pass?: string }) => {
102
+ await startRunner({
103
+ server: opts.server,
104
+ key: opts.key || '',
105
+ pass: opts.pass || '',
106
+ })
107
+ })
108
+
109
+ return program
110
+ }
package/src/publish.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { buildGame } from './pack'
3
+ import { request } from './fetch'
4
+
5
+ export type PublishOptions = {
6
+ cwd: string
7
+ api: string
8
+ token: string
9
+ spaceId?: string
10
+ }
11
+
12
+ const UPLOAD_TIMEOUT_MS = 120_000
13
+
14
+ export async function publishGame(options: PublishOptions) {
15
+ const { dest, manifest } = await buildGame(options.cwd)
16
+ const zip = await readFile(dest)
17
+ const api = options.api.replace(/\/$/, '')
18
+ const path = options.spaceId ? '/portal/games/publishing' : '/games/publishing'
19
+ const url = `${api}${path}`
20
+ const form = new FormData()
21
+ form.append(
22
+ 'file',
23
+ new Blob([new Uint8Array(zip)], { type: 'application/zip' }),
24
+ `${manifest.slug}.zip`,
25
+ )
26
+ if (options.spaceId) form.set('spaceId', options.spaceId)
27
+
28
+ const res = await request(url, {
29
+ method: 'POST',
30
+ headers: {
31
+ authorization: `Bearer ${options.token}`,
32
+ cookie: `better-auth.session_token=${options.token}`,
33
+ ...(options.spaceId ? { 'x-space-id': options.spaceId } : {}),
34
+ },
35
+ body: form,
36
+ timeoutMs: UPLOAD_TIMEOUT_MS,
37
+ })
38
+
39
+ const text = await res.text()
40
+ if (!res.ok) {
41
+ throw new Error(`Publish failed (${res.status}): ${text.slice(0, 500)}`)
42
+ }
43
+ const contentType = res.headers.get('content-type') || ''
44
+ if (!contentType.includes('json')) {
45
+ throw new Error(
46
+ `Publish returned ${res.status} ${contentType || 'without a content type'} instead of JSON: ${text.slice(0, 200)}`,
47
+ )
48
+ }
49
+ try {
50
+ return JSON.parse(text) as {
51
+ id: string
52
+ slug: string
53
+ version: number
54
+ entryPointUrl?: string
55
+ }
56
+ } catch {
57
+ throw new Error(`Publish returned invalid JSON: ${text.slice(0, 200)}`)
58
+ }
59
+ }
@@ -0,0 +1,48 @@
1
+ export type PipelineStageName = 'fetch' | 'build' | 'test' | 'publish'
2
+
3
+ export function planPipeline(
4
+ scripts: { build?: string; test?: string } | null,
5
+ ): PipelineStageName[] {
6
+ const stages: PipelineStageName[] = ['fetch', 'build']
7
+ if (scripts?.test) stages.push('test')
8
+ stages.push('publish')
9
+ return stages
10
+ }
11
+
12
+ export function installCommand(
13
+ files: string[],
14
+ ): [string, string[]] | null {
15
+ if (!files.includes('package.json')) return null
16
+ if (files.includes('pnpm-lock.yaml')) {
17
+ return ['pnpm', ['install', '--frozen-lockfile']]
18
+ }
19
+ if (files.includes('yarn.lock')) {
20
+ return ['yarn', ['install', '--frozen-lockfile']]
21
+ }
22
+ if (files.includes('package-lock.json')) return ['npm', ['ci']]
23
+ return ['npm', ['install']]
24
+ }
25
+
26
+ export function buildCommand(
27
+ files: string[],
28
+ scripts: { build?: string } | null,
29
+ ): [string, string[]] | null {
30
+ if (!scripts?.build) return null
31
+ const [manager] = installCommand(files.includes('package.json') ? files : ['package.json', ...files]) ?? [
32
+ 'npm',
33
+ [],
34
+ ]
35
+ return [manager, ['run', 'build']]
36
+ }
37
+
38
+ export function testCommand(
39
+ files: string[],
40
+ scripts: { test?: string } | null,
41
+ ): [string, string[]] | null {
42
+ if (!scripts?.test) return null
43
+ const [manager] = installCommand(files.includes('package.json') ? files : ['package.json', ...files]) ?? [
44
+ 'npm',
45
+ [],
46
+ ]
47
+ return [manager, ['run', 'test']]
48
+ }
@@ -0,0 +1,35 @@
1
+ export type RunnerReceiptInput = {
2
+ server: string
3
+ key: string
4
+ pass?: string
5
+ }
6
+
7
+ function dockerServer(server: string) {
8
+ try {
9
+ const url = new URL(server)
10
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
11
+ url.hostname = 'host.docker.internal'
12
+ return url.toString().replace(/\/$/, '')
13
+ }
14
+ } catch {
15
+ /* keep */
16
+ }
17
+ return server
18
+ }
19
+
20
+ export function formatRunnerReceipt(input: RunnerReceiptInput) {
21
+ const server = input.server.replace(/\/$/, '')
22
+ const pass = input.pass || '<pass>'
23
+ const flags = `--server ${server} --key ${input.key} --pass ${pass}`
24
+ const dockerApi = dockerServer(server)
25
+ const dockerFlags = `--server ${dockerApi} --key ${input.key} --pass ${pass}`
26
+ return {
27
+ server,
28
+ key: input.key,
29
+ pass,
30
+ cli: `fzgame runner start ${flags}`,
31
+ local: `node packages/fzgame-cli/dist/cli.cjs runner start ${flags}`,
32
+ docker: `docker compose run --rm runner ${dockerFlags}`,
33
+ compose: `FZGAME_API=${dockerApi} FZGAME_RUNNER_KEY=${input.key} FZGAME_RUNNER_PASS=${pass} docker compose up -d runner`,
34
+ }
35
+ }