@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/src/runner.ts ADDED
@@ -0,0 +1,304 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'
3
+ import { hostname } from 'node:os'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { request, type RequestOptions } from './fetch'
7
+ import { buildGame } from './pack'
8
+ import {
9
+ buildCommand,
10
+ installCommand,
11
+ planPipeline,
12
+ testCommand,
13
+ } from './runner-pipeline'
14
+ import { VERSION } from './version'
15
+
16
+ export type RunnerStartOptions = {
17
+ server: string
18
+ key: string
19
+ pass: string
20
+ }
21
+
22
+ type RunnerJob = {
23
+ deploymentId: string
24
+ owner: string
25
+ repo: string
26
+ commitSha: string
27
+ branch?: string | null
28
+ rootDirectory?: string
29
+ cloneToken: string
30
+ }
31
+
32
+ const EXAMPLE =
33
+ 'fzgame runner start --server http://localhost:3020 --key <key> --pass <pass>'
34
+
35
+ const CLAIM_TIMEOUT_MS = 30_000
36
+ const IDLE_SLEEP_MS = 2000
37
+ const MIN_BACKOFF_MS = 2000
38
+ const MAX_BACKOFF_MS = 30_000
39
+ const UPLOAD_TIMEOUT_MS = 120_000
40
+ const LOG_FLUSH_MS = 250
41
+ const LOG_MAX_CHARS = 8000
42
+
43
+ export async function startRunner(opts: RunnerStartOptions) {
44
+ const server = (opts.server || '').replace(/\/$/, '')
45
+ const key = opts.key || ''
46
+ const pass = opts.pass || ''
47
+ if (!server || !key || !pass) {
48
+ throw new Error(`Pass --server, --key, and --pass. Example: ${EXAMPLE}`)
49
+ }
50
+
51
+ const headers = {
52
+ 'x-runner-key': key,
53
+ 'x-runner-pass': pass,
54
+ }
55
+
56
+ const connected = await api(server, '/internal/ci-runners/connect', {
57
+ method: 'POST',
58
+ headers: { ...headers, 'Content-Type': 'application/json' },
59
+ body: JSON.stringify({
60
+ key,
61
+ pass,
62
+ hostname: hostname(),
63
+ version: VERSION,
64
+ }),
65
+ })
66
+ console.log(`Runner ${connected.name || key} connected to ${server}`)
67
+ console.log('Waiting for queued git deploys (fetch → build → test → publish)')
68
+
69
+ let stopping = false
70
+ const beat = setInterval(() => {
71
+ api(server, '/internal/ci-runners/heartbeat', {
72
+ method: 'POST',
73
+ headers,
74
+ }).catch(() => undefined)
75
+ }, 15_000)
76
+
77
+ const stop = () => {
78
+ if (stopping) return
79
+ stopping = true
80
+ clearInterval(beat)
81
+ api(server, '/internal/ci-runners/disconnect', {
82
+ method: 'POST',
83
+ headers,
84
+ })
85
+ .catch(() => undefined)
86
+ .finally(() => process.exit(0))
87
+ }
88
+ process.on('SIGINT', stop)
89
+ process.on('SIGTERM', stop)
90
+
91
+ let backoff = MIN_BACKOFF_MS
92
+ const retry = async (message: string) => {
93
+ console.error(`${message}; retrying in ${Math.round(backoff / 1000)}s`)
94
+ await sleep(backoff)
95
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS)
96
+ }
97
+
98
+ while (!stopping) {
99
+ let res: Response
100
+ try {
101
+ res = await request(`${server}/internal/ci-runners/jobs/next`, {
102
+ headers,
103
+ timeoutMs: CLAIM_TIMEOUT_MS,
104
+ })
105
+ } catch (error) {
106
+ await retry(`claim failed: ${(error as Error).message}`)
107
+ continue
108
+ }
109
+ backoff = MIN_BACKOFF_MS
110
+
111
+ if (res.status === 204) {
112
+ await sleep(IDLE_SLEEP_MS)
113
+ continue
114
+ }
115
+ const text = await res.text()
116
+ if (!res.ok) {
117
+ await retry(`claim failed (${res.status}): ${text.slice(0, 200)}`)
118
+ continue
119
+ }
120
+ if (!text) {
121
+ await sleep(IDLE_SLEEP_MS)
122
+ continue
123
+ }
124
+
125
+ let job: RunnerJob
126
+ try {
127
+ job = JSON.parse(text) as RunnerJob
128
+ } catch {
129
+ await retry(`claim returned invalid JSON: ${text.slice(0, 200)}`)
130
+ continue
131
+ }
132
+
133
+ console.log(`Job ${job.deploymentId} ${job.owner}/${job.repo}@${job.commitSha.slice(0, 7)}`)
134
+ try {
135
+ await runJob(server, headers, job)
136
+ console.log(`Job ${job.deploymentId} ready`)
137
+ } catch (err) {
138
+ const message = (err as Error).message
139
+ console.error(`Job ${job.deploymentId} failed: ${message}`)
140
+ await api(server, `/internal/ci-runners/jobs/${job.deploymentId}/fail`, {
141
+ method: 'POST',
142
+ headers: { ...headers, 'Content-Type': 'application/json' },
143
+ body: JSON.stringify({ message }),
144
+ }).catch(() => undefined)
145
+ }
146
+ }
147
+ }
148
+
149
+ async function runJob(
150
+ server: string,
151
+ headers: Record<string, string>,
152
+ job: RunnerJob,
153
+ ) {
154
+ const work = await mkdtemp(join(tmpdir(), 'fzgame-run-'))
155
+
156
+ // Build output arrives in many small chunks; coalesce them so a chatty build
157
+ // does not turn into one HTTP request per chunk.
158
+ let pending = ''
159
+ let flushTimer: NodeJS.Timeout | undefined
160
+ const flushLogs = async () => {
161
+ if (flushTimer) {
162
+ clearTimeout(flushTimer)
163
+ flushTimer = undefined
164
+ }
165
+ if (!pending) return
166
+ const chunk = pending
167
+ pending = ''
168
+ await api(server, `/internal/ci-runners/jobs/${job.deploymentId}/logs`, {
169
+ method: 'POST',
170
+ headers: { ...headers, 'Content-Type': 'application/json' },
171
+ body: JSON.stringify({ chunk }),
172
+ }).catch(() => undefined)
173
+ }
174
+ const log = (chunk: string) => {
175
+ process.stdout.write(chunk)
176
+ pending += chunk
177
+ if (pending.length >= LOG_MAX_CHARS) {
178
+ void flushLogs()
179
+ return
180
+ }
181
+ if (!flushTimer) {
182
+ flushTimer = setTimeout(() => void flushLogs(), LOG_FLUSH_MS)
183
+ flushTimer.unref()
184
+ }
185
+ }
186
+ const stage = async (name: string, status: string, error?: string) => {
187
+ await flushLogs()
188
+ await api(server, `/internal/ci-runners/jobs/${job.deploymentId}/stage`, {
189
+ method: 'POST',
190
+ headers: { ...headers, 'Content-Type': 'application/json' },
191
+ body: JSON.stringify({ name, status, error }),
192
+ })
193
+ }
194
+
195
+ try {
196
+ await stage('fetch', 'running')
197
+ log(`fetch ${job.owner}/${job.repo} ${job.commitSha}\n`)
198
+ await runCmd('git', ['init'], work, log)
199
+ const origin = `https://x-access-token:${job.cloneToken}@github.com/${job.owner}/${job.repo}.git`
200
+ await runCmd('git', ['remote', 'add', 'origin', origin], work, () => undefined)
201
+ await runCmd('git', ['fetch', '--depth', '1', 'origin', job.commitSha], work, log)
202
+ await runCmd('git', ['checkout', '--force', 'FETCH_HEAD'], work, log)
203
+ await stage('fetch', 'ok')
204
+
205
+ const packRoot = job.rootDirectory ? join(work, job.rootDirectory) : work
206
+ const files = await readdir(packRoot)
207
+ const scripts = await readScripts(packRoot)
208
+ const planned = planPipeline(scripts)
209
+
210
+ await stage('build', 'running')
211
+ const install = installCommand(files)
212
+ if (install) await runCmd(install[0], install[1], packRoot, log)
213
+ const build = buildCommand(files, scripts)
214
+ if (build) await runCmd(build[0], build[1], packRoot, log)
215
+ await stage('build', 'ok')
216
+
217
+ if (planned.includes('test')) {
218
+ await stage('test', 'running')
219
+ const test = testCommand(files, scripts)
220
+ if (test) await runCmd(test[0], test[1], packRoot, log)
221
+ await stage('test', 'ok')
222
+ } else {
223
+ await stage('test', 'skipped')
224
+ }
225
+
226
+ await stage('publish', 'running')
227
+ const packed = await buildGame(packRoot)
228
+ log(`packed ${packed.dest}\n`)
229
+ const zip = await readFile(packed.dest)
230
+ const form = new FormData()
231
+ form.append(
232
+ 'file',
233
+ new Blob([new Uint8Array(zip)], { type: 'application/zip' }),
234
+ 'game.zip',
235
+ )
236
+ const published = await request(
237
+ `${server}/internal/ci-runners/jobs/${job.deploymentId}/artifact`,
238
+ { method: 'POST', headers, body: form, timeoutMs: UPLOAD_TIMEOUT_MS },
239
+ )
240
+ if (!published.ok) {
241
+ throw new Error(`publish failed (${published.status}): ${await published.text()}`)
242
+ }
243
+ await stage('publish', 'ok')
244
+ } finally {
245
+ await flushLogs()
246
+ await rm(work, { recursive: true, force: true })
247
+ }
248
+ }
249
+
250
+ async function readScripts(root: string) {
251
+ try {
252
+ const raw = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as {
253
+ scripts?: { build?: string; test?: string }
254
+ }
255
+ return raw.scripts ?? {}
256
+ } catch {
257
+ return null
258
+ }
259
+ }
260
+
261
+ function runCmd(
262
+ cmd: string,
263
+ args: string[],
264
+ cwd: string,
265
+ onLog: (chunk: string) => void,
266
+ ) {
267
+ return new Promise<void>((resolve, reject) => {
268
+ // pnpm/npm/yarn are .cmd shims on Windows and only resolve through cmd.exe.
269
+ const useShell = process.platform === 'win32'
270
+ const child = spawn(cmd, useShell ? args.map(quoteForShell) : args, {
271
+ cwd,
272
+ env: process.env,
273
+ shell: useShell,
274
+ })
275
+ child.stdout.on('data', (buf: Buffer) => onLog(buf.toString()))
276
+ child.stderr.on('data', (buf: Buffer) => onLog(buf.toString()))
277
+ child.on('error', reject)
278
+ child.on('close', (code, signal) => {
279
+ if (code === 0) resolve()
280
+ else if (signal) reject(new Error(`${cmd} ${args.join(' ')} killed by ${signal}`))
281
+ else reject(new Error(`${cmd} ${args.join(' ')} exited ${code}`))
282
+ })
283
+ })
284
+ }
285
+
286
+ function quoteForShell(arg: string) {
287
+ return /[\s"&|<>^()]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg
288
+ }
289
+
290
+ async function api(server: string, path: string, init: RequestOptions) {
291
+ const res = await request(`${server}${path}`, init)
292
+ const text = await res.text()
293
+ if (!res.ok) throw new Error(`${path} failed (${res.status}): ${text.slice(0, 500)}`)
294
+ if (!text) return {} as { name?: string }
295
+ try {
296
+ return JSON.parse(text) as { name?: string }
297
+ } catch {
298
+ throw new Error(`${path} returned invalid JSON: ${text.slice(0, 200)}`)
299
+ }
300
+ }
301
+
302
+ function sleep(ms: number) {
303
+ return new Promise((resolve) => setTimeout(resolve, ms))
304
+ }
package/src/version.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ // Resolves to the package root from both src/ (vitest) and dist/ (the CJS bundle).
6
+ const here = dirname(fileURLToPath(import.meta.url))
7
+
8
+ function readVersion(): string {
9
+ try {
10
+ const raw = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')) as {
11
+ version?: string
12
+ }
13
+ return raw.version ?? '0.0.0'
14
+ } catch {
15
+ return '0.0.0'
16
+ }
17
+ }
18
+
19
+ export const VERSION = readVersion()
package/src/zip.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { deflateRawSync } from 'node:zlib'
2
+
3
+ // Fixed 1980-01-01 00:00 DOS timestamp: byte-identical archives for identical
4
+ // input, with month and day fields that real extractors accept.
5
+ const DOS_TIME = 0
6
+ const DOS_DATE = 0x0021
7
+
8
+ const CRC_TABLE = new Uint32Array(256)
9
+ for (let n = 0; n < 256; n++) {
10
+ let c = n
11
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
12
+ CRC_TABLE[n] = c
13
+ }
14
+
15
+ function crc32(buf: Buffer): number {
16
+ let c = 0xffffffff
17
+ for (let i = 0; i < buf.length; i++) {
18
+ c = CRC_TABLE[(c ^ buf[i]!) & 0xff]! ^ (c >>> 8)
19
+ }
20
+ return (c ^ 0xffffffff) >>> 0
21
+ }
22
+
23
+ export function buildZip(files: Record<string, Buffer | string>): Buffer {
24
+ const records: Array<{
25
+ name: Buffer
26
+ data: Buffer
27
+ compressed: Buffer
28
+ crc: number
29
+ offset: number
30
+ }> = []
31
+ const parts: Buffer[] = []
32
+ let offset = 0
33
+
34
+ for (const [name, content] of Object.entries(files)) {
35
+ const data = Buffer.isBuffer(content) ? content : Buffer.from(content)
36
+ const compressed = deflateRawSync(data)
37
+ const crc = crc32(data)
38
+ const nameBuf = Buffer.from(name.replace(/\\/g, '/'), 'utf8')
39
+ const local = Buffer.alloc(30)
40
+ local.writeUInt32LE(0x04034b50, 0)
41
+ local.writeUInt16LE(20, 4)
42
+ local.writeUInt16LE(8, 8)
43
+ local.writeUInt16LE(DOS_TIME, 10)
44
+ local.writeUInt16LE(DOS_DATE, 12)
45
+ local.writeUInt32LE(crc, 14)
46
+ local.writeUInt32LE(compressed.length, 18)
47
+ local.writeUInt32LE(data.length, 22)
48
+ local.writeUInt16LE(nameBuf.length, 26)
49
+ parts.push(local, nameBuf, compressed)
50
+ records.push({ name: nameBuf, data, compressed, crc, offset })
51
+ offset += local.length + nameBuf.length + compressed.length
52
+ }
53
+
54
+ const centralStart = offset
55
+ for (const record of records) {
56
+ const central = Buffer.alloc(46)
57
+ central.writeUInt32LE(0x02014b50, 0)
58
+ central.writeUInt16LE(20, 4)
59
+ central.writeUInt16LE(20, 6)
60
+ central.writeUInt16LE(8, 10)
61
+ central.writeUInt16LE(DOS_TIME, 12)
62
+ central.writeUInt16LE(DOS_DATE, 14)
63
+ central.writeUInt32LE(record.crc, 16)
64
+ central.writeUInt32LE(record.compressed.length, 20)
65
+ central.writeUInt32LE(record.data.length, 24)
66
+ central.writeUInt16LE(record.name.length, 28)
67
+ central.writeUInt32LE(record.offset, 42)
68
+ parts.push(central, record.name)
69
+ offset += central.length + record.name.length
70
+ }
71
+
72
+ const eocd = Buffer.alloc(22)
73
+ eocd.writeUInt32LE(0x06054b50, 0)
74
+ eocd.writeUInt16LE(records.length, 8)
75
+ eocd.writeUInt16LE(records.length, 10)
76
+ eocd.writeUInt32LE(offset - centralStart, 12)
77
+ eocd.writeUInt32LE(centralStart, 16)
78
+ parts.push(eocd)
79
+ return Buffer.concat(parts)
80
+ }