@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/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
|
+
}
|