@boardgamebuddy/game-pack-cli 0.0.1

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.
Files changed (2) hide show
  1. package/cli.js +271 -0
  2. package/package.json +13 -0
package/cli.js ADDED
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bgb — BoardGameBuddy game pack developer CLI
4
+ *
5
+ * Commands:
6
+ * bgb new <game-id> [--name "Display Name"] scaffold a new pack from the upstream template
7
+ * bgb serve [pack-dir] serve a pack with live reload on scorer.ts changes
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const { program } = require('commander');
13
+ const https = require('https');
14
+ const http = require('http');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const os = require('os');
18
+ const { spawn } = require('child_process');
19
+
20
+ const PORT = parseInt(process.env.PORT || '3000', 10);
21
+
22
+ const TEMPLATE_REPO = 'BoardGameBuddy/game-packs';
23
+ const TEMPLATE_PATH = 'games/_template';
24
+ const TEMPLATE_REF = 'main';
25
+
26
+ // ─── Helpers ────────────────────────────────────────────────────────────────
27
+
28
+ function getLocalIp() {
29
+ for (const ifaces of Object.values(os.networkInterfaces())) {
30
+ for (const iface of ifaces) {
31
+ if (iface.family === 'IPv4' && !iface.internal) return iface.address;
32
+ }
33
+ }
34
+ return 'localhost';
35
+ }
36
+
37
+ function toTitleCase(str) {
38
+ return str
39
+ .split('-')
40
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
41
+ .join(' ');
42
+ }
43
+
44
+ function get(url) {
45
+ return new Promise((resolve, reject) => {
46
+ const lib = url.startsWith('https') ? https : http;
47
+ lib.get(url, { headers: { 'User-Agent': 'bgb-cli' } }, (res) => {
48
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
49
+ resolve(get(res.headers.location));
50
+ return;
51
+ }
52
+ const chunks = [];
53
+ res.on('data', chunk => chunks.push(chunk));
54
+ res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks) }));
55
+ }).on('error', reject);
56
+ });
57
+ }
58
+
59
+ async function githubContents(repoPath) {
60
+ const url = `https://api.github.com/repos/${TEMPLATE_REPO}/contents/${repoPath}?ref=${TEMPLATE_REF}`;
61
+ const { status, body } = await get(url);
62
+ if (status !== 200) {
63
+ throw new Error(`GitHub API returned ${status} for ${repoPath}:\n${body.toString()}`);
64
+ }
65
+ return JSON.parse(body.toString());
66
+ }
67
+
68
+ async function downloadTemplate(repoPath, localDir) {
69
+ const entries = await githubContents(repoPath);
70
+ fs.mkdirSync(localDir, { recursive: true });
71
+ for (const entry of entries) {
72
+ const dest = path.join(localDir, entry.name);
73
+ if (entry.type === 'dir') {
74
+ await downloadTemplate(entry.path, dest);
75
+ } else if (entry.type === 'file') {
76
+ const { status, body } = await get(entry.download_url);
77
+ if (status !== 200) throw new Error(`Failed to download ${entry.path}: HTTP ${status}`);
78
+ fs.writeFileSync(dest, body);
79
+ }
80
+ }
81
+ }
82
+
83
+ // ─── bgb new ────────────────────────────────────────────────────────────────
84
+
85
+ program
86
+ .command('new <game-id>')
87
+ .description('Scaffold a new game pack from the upstream template')
88
+ .option('-n, --name <displayName>', 'Display name for the game (defaults to title-cased game-id)')
89
+ .action(async (gameId, opts) => {
90
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(gameId)) {
91
+ console.error(`Error: game-id must be lowercase alphanumeric and hyphens only (got: "${gameId}")`);
92
+ process.exit(1);
93
+ }
94
+
95
+ const displayName = opts.name || toTitleCase(gameId);
96
+ const targetDir = path.join(process.cwd(), 'games', gameId);
97
+
98
+ if (fs.existsSync(targetDir)) {
99
+ console.error(`Error: directory already exists: ${targetDir}`);
100
+ process.exit(1);
101
+ }
102
+
103
+ console.log(`Fetching template from ${TEMPLATE_REPO}...`);
104
+ try {
105
+ await downloadTemplate(TEMPLATE_PATH, targetDir);
106
+ } catch (err) {
107
+ console.error(`Error: failed to fetch template — ${err.message}`);
108
+ process.exit(1);
109
+ }
110
+
111
+ // Patch game.json
112
+ const gameJsonPath = path.join(targetDir, 'game.json');
113
+ let gameJson = fs.readFileSync(gameJsonPath, 'utf8');
114
+ gameJson = gameJson.replace(/"mygame"/g, `"${gameId}"`);
115
+ gameJson = gameJson.replace(/"My Game"/g, `"${displayName}"`);
116
+ fs.writeFileSync(gameJsonPath, gameJson);
117
+
118
+ // Patch embeddings.json
119
+ const embeddingsPath = path.join(targetDir, 'embeddings.json');
120
+ if (fs.existsSync(embeddingsPath)) {
121
+ let embeddings = fs.readFileSync(embeddingsPath, 'utf8');
122
+ embeddings = embeddings.replace(/"mygame:/g, `"${gameId}:`);
123
+ fs.writeFileSync(embeddingsPath, embeddings);
124
+ }
125
+
126
+ console.log(`\nCreated game pack: games/${gameId}/`);
127
+ console.log(` Display name: ${displayName}`);
128
+ console.log(`\nNext steps:`);
129
+ console.log(` cd games/${gameId}`);
130
+ console.log(` # Edit scorer.ts to implement your scoring logic`);
131
+ console.log(` bgb serve . # Start dev server with live reload`);
132
+ console.log('');
133
+ });
134
+
135
+ // ─── bgb serve ──────────────────────────────────────────────────────────────
136
+
137
+ program
138
+ .command('serve [pack-dir]')
139
+ .description('Serve a game pack with live reload on scorer.ts changes')
140
+ .action((packDirArg) => {
141
+ const packDir = packDirArg ? path.resolve(packDirArg) : process.cwd();
142
+ const gameJsonPath = path.join(packDir, 'game.json');
143
+
144
+ if (!fs.existsSync(gameJsonPath)) {
145
+ console.error(`Error: no game.json found in ${packDir}`);
146
+ console.error('Run this command from a pack directory or pass the pack path as argument.');
147
+ process.exit(1);
148
+ }
149
+
150
+ const packName = path.basename(packDir);
151
+
152
+ const MIME = {
153
+ '.json': 'application/json',
154
+ '.js': 'application/javascript',
155
+ '.html': 'text/html',
156
+ '.txt': 'text/plain',
157
+ };
158
+
159
+ const sseClients = new Set();
160
+
161
+ function broadcast(data) {
162
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
163
+ for (const res of sseClients) res.write(msg);
164
+ }
165
+
166
+ const server = http.createServer((req, res) => {
167
+ res.setHeader('Access-Control-Allow-Origin', '*');
168
+ res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
169
+
170
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
171
+
172
+ const urlPath = req.url.split('?')[0];
173
+
174
+ if (urlPath === '/events') {
175
+ res.writeHead(200, {
176
+ 'Content-Type': 'text/event-stream',
177
+ 'Cache-Control': 'no-cache',
178
+ 'Connection': 'keep-alive',
179
+ 'Access-Control-Allow-Origin': '*',
180
+ });
181
+ res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
182
+ sseClients.add(res);
183
+
184
+ const heartbeat = setInterval(() => res.write(': ping\n\n'), 30000);
185
+ req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); });
186
+ return;
187
+ }
188
+
189
+ const filePath = path.join(packDir, urlPath === '/' ? '' : urlPath);
190
+
191
+ if (!filePath.startsWith(packDir)) {
192
+ res.writeHead(403); res.end('Forbidden'); return;
193
+ }
194
+
195
+ console.log(`${req.method} ${urlPath}`);
196
+
197
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
198
+ const ext = path.extname(filePath).toLowerCase();
199
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
200
+ fs.createReadStream(filePath).pipe(res);
201
+ } else {
202
+ res.writeHead(404); res.end('Not found');
203
+ }
204
+ });
205
+
206
+ server.listen(PORT, '0.0.0.0', () => {
207
+ const ip = getLocalIp();
208
+ const url = `http://${ip}:${PORT}/`;
209
+
210
+ console.log(`\nServing pack: ${packName}`);
211
+ console.log(`URL: ${url}\n`);
212
+
213
+ try {
214
+ const qrcode = require('qrcode-terminal');
215
+ qrcode.generate(url, { small: true }, (qr) => {
216
+ console.log(qr);
217
+ console.log('In the app: Pack Store → QR-Code scannen → fertig.\n');
218
+ console.log('Watching scorer.ts for changes... Press Ctrl+C to stop.\n');
219
+ });
220
+ } catch {
221
+ console.log(`In the app: Pack Store → Von URL importieren → ${url}\n`);
222
+ console.log('Watching scorer.ts for changes... Press Ctrl+C to stop.\n');
223
+ }
224
+ });
225
+
226
+ const scorerTs = path.join(packDir, 'scorer.ts');
227
+ if (!fs.existsSync(scorerTs)) {
228
+ console.warn('Warning: scorer.ts not found — live reload disabled.');
229
+ return;
230
+ }
231
+
232
+ let debounceTimer = null;
233
+
234
+ function recompile() {
235
+ console.log('scorer.ts changed — recompiling...');
236
+ const proc = spawn(
237
+ 'npx',
238
+ ['tsc', 'scorer.ts', '--outDir', '.', '--target', 'ES2017', '--module', 'commonjs', '--skipLibCheck'],
239
+ { cwd: packDir, shell: true }
240
+ );
241
+
242
+ let stderr = '';
243
+ proc.stderr.on('data', chunk => { stderr += chunk.toString(); });
244
+ proc.stdout.on('data', chunk => { process.stdout.write(chunk); });
245
+
246
+ proc.on('close', (code) => {
247
+ if (code === 0) {
248
+ console.log('Recompile OK — broadcasting reload.');
249
+ broadcast({ type: 'reload' });
250
+ } else {
251
+ const msg = stderr.trim() || 'Compilation failed (see above)';
252
+ console.error(`Recompile failed:\n${msg}`);
253
+ broadcast({ type: 'error', message: msg });
254
+ }
255
+ });
256
+ }
257
+
258
+ fs.watch(scorerTs, { persistent: true }, () => {
259
+ clearTimeout(debounceTimer);
260
+ debounceTimer = setTimeout(recompile, 300);
261
+ });
262
+ });
263
+
264
+ // ─── Parse ───────────────────────────────────────────────────────────────────
265
+
266
+ program
267
+ .name('bgb')
268
+ .description('BoardGameBuddy game pack developer CLI')
269
+ .version('1.0.0');
270
+
271
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@boardgamebuddy/game-pack-cli",
3
+ "version": "0.0.1",
4
+ "description": "Developer CLI for BoardGameBuddy game packs",
5
+ "bin": { "bgb": "./cli.js" },
6
+ "files": ["cli.js"],
7
+ "engines": { "node": ">=18" },
8
+ "publishConfig": { "access": "public" },
9
+ "dependencies": {
10
+ "commander": "^12.0.0",
11
+ "qrcode-terminal": "^0.12.0"
12
+ }
13
+ }