@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.
- package/LICENSE +15 -0
- package/README.md +313 -2
- package/dist/cli.cjs +1077 -0
- package/dist/cli.cjs.map +1 -0
- package/example/click-counter/README.md +11 -0
- package/example/click-counter/editor/index.html +68 -0
- package/example/click-counter/fzgame.json +65 -0
- package/example/click-counter/game/index.html +75 -0
- package/example/click-counter/handler/index.js +13 -0
- package/package.json +61 -4
- package/src/cli.ts +11 -0
- package/src/dev.ts +311 -0
- package/src/env.ts +30 -0
- package/src/fetch.ts +37 -0
- package/src/fzgame.schema.json +69 -0
- package/src/init.ts +74 -0
- package/src/kit-path.ts +40 -0
- package/src/manifest.ts +182 -0
- package/src/pack.ts +108 -0
- package/src/program.ts +110 -0
- package/src/publish.ts +59 -0
- package/src/runner-pipeline.ts +48 -0
- package/src/runner-receipt.ts +35 -0
- package/src/runner.ts +304 -0
- package/src/version.ts +19 -0
- package/src/zip.ts +80 -0
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|