castle-web-cli 0.3.0 → 0.4.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.
package/AGENTS.md CHANGED
@@ -3,11 +3,11 @@
3
3
  ## Commands
4
4
 
5
5
  - `npx castle-web-cli init <dir>` — scaffold a new project (index.html, game.js, package.json with castle-web-sdk)
6
- - `npx castle-web-cli serve [dir] [--port PORT] [--open]` — local dev server with Vite (default port 3737, WS on 3738). No auto-reload — use `restart` to reload.
7
- - `npx castle-web-cli restart [--port PORT]` — reload the game in the browser (default WS port 3738)
8
- - `npx castle-web-cli screenshot [--out FILE] [--port PORT]` — capture a screenshot from the running game (default: screenshot.png)
9
- - `npx castle-web-cli push [dir]` — bundle with Vite and publish to Castle. Creates a new deck if no castle.json exists. Automatically saves a preview image on first push if `serve` is running.
10
- - `npx castle-web-cli save-preview-image [dir] [--port PORT]` — restarts the deck, takes a screenshot, uploads it as the deck's preview image. Requires `serve` running and `castle.json` to exist (push first).
6
+ - `npx castle-web-cli serve [dir] [--port PORT] [--open]` — local dev server with Vite (default port 3737, WS on 3738). Writes `.castle/serve.json` with active ports. No auto-reload — use `restart` to reload.
7
+ - `npx castle-web-cli restart [dir] [--port PORT]` — reload the game in the browser. Reads WS port from `.castle/serve.json` if no `--port` given.
8
+ - `npx castle-web-cli screenshot [dir] [--out FILE] [--port PORT]` — capture a screenshot from the running game (default: screenshot.png). Reads WS port from `.castle/serve.json` if no `--port` given.
9
+ - `npx castle-web-cli push [dir]` — bundle with Vite and publish to Castle. Creates a new deck if no castle.json exists. Automatically saves a preview image on first push if `serve` is running (reads WS port from `.castle/serve.json`).
10
+ - `npx castle-web-cli save-preview-image [dir] [--port PORT] [--no-restart]` — restarts the deck, takes a screenshot, uploads it as the deck's preview image. Requires `serve` running and `castle.json` to exist (push first). Reads WS port from `.castle/serve.json` if no `--port` given.
11
11
  - `npx castle-web-cli login` — authenticate with Castle (saves token to ~/.castle/config.json)
12
12
 
13
13
  ## Project Structure
@@ -18,7 +18,8 @@ my-game/
18
18
  game.js # game code
19
19
  package.json # dependencies (castle-web-sdk)
20
20
  castle.json # deck/card IDs (created by first push)
21
- .castle/ # runtime state (logs, screenshots)
21
+ .castle/ # runtime state (logs, screenshots, serve port)
22
+ serve.json # active serve ports (written by serve, cleaned up on exit)
22
23
  logs.txt # forwarded console.log/warn/error
23
24
  screenshots/ # captured screenshots
24
25
  ```
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import * as fs from 'fs';
3
+ import * as path from 'path';
3
4
  import { login } from './login.js';
4
5
  import { serve } from './serve.js';
5
6
  import { push } from './push.js';
@@ -7,13 +8,47 @@ import { init } from './init.js';
7
8
  import { savePreviewImage, savePreviewIfNeeded } from './preview.js';
8
9
  const args = process.argv.slice(2);
9
10
  const command = args[0];
11
+ const FLAGS_WITH_VALUES = new Set(['--port', '--out']);
12
+ function findPositionalDir() {
13
+ for (let i = 1; i < args.length; i++) {
14
+ if (args[i].startsWith('--')) {
15
+ if (FLAGS_WITH_VALUES.has(args[i]))
16
+ i++;
17
+ continue;
18
+ }
19
+ return args[i];
20
+ }
21
+ return '.';
22
+ }
23
+ function getFlagValue(flag) {
24
+ const idx = args.indexOf(flag);
25
+ return idx >= 0 ? args[idx + 1] : undefined;
26
+ }
27
+ function readServeWsPort(dir) {
28
+ try {
29
+ const serveJson = JSON.parse(fs.readFileSync(path.join(path.resolve(dir), '.castle', 'serve.json'), 'utf-8'));
30
+ return serveJson.wsPort;
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ function getWsPort(dir) {
37
+ const explicit = getFlagValue('--port');
38
+ if (explicit)
39
+ return parseInt(explicit, 10);
40
+ const fromServe = readServeWsPort(dir);
41
+ if (fromServe)
42
+ return fromServe;
43
+ return 3738;
44
+ }
10
45
  function usage() {
11
46
  console.log(`Usage:
12
47
  castle-web init <dir>
13
48
  castle-web serve [dir] [--port PORT] [--open]
14
49
  castle-web restart [--port PORT]
15
50
  castle-web screenshot [--out FILE] [--port PORT]
16
- castle-web save-preview-image [dir] [--port PORT]
51
+ castle-web save-preview-image [dir] [--port PORT] [--no-restart]
17
52
  castle-web push [dir]
18
53
  castle-web login`);
19
54
  process.exit(1);
@@ -30,24 +65,22 @@ async function main() {
30
65
  break;
31
66
  }
32
67
  case 'serve': {
33
- const dir = args.find((a, i) => i > 0 && !a.startsWith('--')) ?? '.';
34
- const portIdx = args.indexOf('--port');
35
- const port = portIdx >= 0 ? args[portIdx + 1] : undefined;
68
+ const dir = findPositionalDir();
69
+ const port = getFlagValue('--port');
36
70
  const open = args.includes('--open');
37
71
  await serve(dir, { port, open });
38
72
  break;
39
73
  }
40
74
  case 'push': {
41
- const dir = args.find((a, i) => i > 0 && !a.startsWith('--')) ?? '.';
42
- const portIdx = args.indexOf('--port');
43
- const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
75
+ const dir = findPositionalDir();
44
76
  await push(dir);
77
+ const wsPort = getWsPort(dir);
45
78
  await savePreviewIfNeeded(dir, wsPort);
46
79
  break;
47
80
  }
48
81
  case 'restart': {
49
- const portIdx = args.indexOf('--port');
50
- const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
82
+ const dir = findPositionalDir();
83
+ const wsPort = getWsPort(dir);
51
84
  const { default: WS } = await import('ws');
52
85
  const ws = new WS(`ws://localhost:${wsPort}`);
53
86
  ws.on('open', () => {
@@ -58,10 +91,9 @@ async function main() {
58
91
  break;
59
92
  }
60
93
  case 'screenshot': {
61
- const outIdx = args.indexOf('--out');
62
- const outFile = outIdx >= 0 ? args[outIdx + 1] : 'screenshot.png';
63
- const portIdx = args.indexOf('--port');
64
- const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
94
+ const dir = findPositionalDir();
95
+ const outFile = getFlagValue('--out') ?? 'screenshot.png';
96
+ const wsPort = getWsPort(dir);
65
97
  const requestId = Math.random().toString(36).slice(2);
66
98
  const { default: WS } = await import('ws');
67
99
  const ws = new WS(`ws://localhost:${wsPort}`);
@@ -90,10 +122,10 @@ async function main() {
90
122
  break;
91
123
  }
92
124
  case 'save-preview-image': {
93
- const dir = args.find((a, i) => i > 0 && !a.startsWith('--')) ?? '.';
94
- const portIdx = args.indexOf('--port');
95
- const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
96
- await savePreviewImage(dir, wsPort);
125
+ const dir = findPositionalDir();
126
+ const wsPort = getWsPort(dir);
127
+ const noRestart = args.includes('--no-restart');
128
+ await savePreviewImage(dir, wsPort, noRestart);
97
129
  process.exit(0);
98
130
  }
99
131
  case 'login':
package/dist/preview.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare function savePreviewImage(dir: string, wsPort: number): Promise<void>;
1
+ export declare function savePreviewImage(dir: string, wsPort: number, noRestart?: boolean): Promise<void>;
2
2
  export declare function savePreviewIfNeeded(dir: string, wsPort: number): Promise<void>;
package/dist/preview.js CHANGED
@@ -15,7 +15,7 @@ function takeScreenshot(ws) {
15
15
  const requestId = Math.random().toString(36).slice(2);
16
16
  const timeout = setTimeout(() => {
17
17
  reject(new Error('Screenshot timed out.'));
18
- }, 5000);
18
+ }, 10000);
19
19
  ws.on('message', (raw) => {
20
20
  try {
21
21
  const msg = JSON.parse(raw.toString());
@@ -31,12 +31,11 @@ function takeScreenshot(ws) {
31
31
  }
32
32
  function waitForRestart(ws) {
33
33
  return new Promise((resolve) => {
34
- // restart, wait a moment for the deck to render its first frame
35
34
  ws.send(JSON.stringify({ type: 'restart' }));
36
- setTimeout(resolve, 500);
35
+ setTimeout(resolve, 2000);
37
36
  });
38
37
  }
39
- export async function savePreviewImage(dir, wsPort) {
38
+ export async function savePreviewImage(dir, wsPort, noRestart = false) {
40
39
  const projectDir = path.resolve(dir);
41
40
  const castleJsonPath = path.join(projectDir, 'castle.json');
42
41
  if (!fs.existsSync(castleJsonPath)) {
@@ -49,7 +48,8 @@ export async function savePreviewImage(dir, wsPort) {
49
48
  process.exit(1);
50
49
  }
51
50
  const ws = await connectWS(wsPort);
52
- await waitForRestart(ws);
51
+ if (!noRestart)
52
+ await waitForRestart(ws);
53
53
  const base64 = await takeScreenshot(ws);
54
54
  ws.close();
55
55
  fs.writeFileSync(path.join(projectDir, 'preview.png'), Buffer.from(base64, 'base64'));
@@ -63,8 +63,9 @@ export async function savePreviewIfNeeded(dir, wsPort) {
63
63
  if (fs.existsSync(path.join(projectDir, 'preview.png'))) {
64
64
  return;
65
65
  }
66
+ let ws;
66
67
  try {
67
- const ws = await connectWS(wsPort);
68
+ ws = await connectWS(wsPort);
68
69
  await waitForRestart(ws);
69
70
  const base64 = await takeScreenshot(ws);
70
71
  ws.close();
@@ -78,7 +79,9 @@ export async function savePreviewIfNeeded(dir, wsPort) {
78
79
  await api.updateCardCustomBackgroundImage(castleJson.cardId, file.fileId);
79
80
  console.log('Set deck preview image.');
80
81
  }
81
- catch {
82
- // serve not running, skip preview
82
+ catch (e) {
83
+ if (ws)
84
+ ws.close();
85
+ console.log(`Preview skipped: ${e?.message ?? e}`);
83
86
  }
84
87
  }
package/dist/serve.js CHANGED
@@ -1,7 +1,23 @@
1
1
  import * as fs from 'fs';
2
+ import * as net from 'net';
2
3
  import * as path from 'path';
3
4
  import { createServer } from 'vite';
4
5
  import { WebSocketServer, WebSocket } from 'ws';
6
+ function isPortFree(port) {
7
+ return new Promise((resolve) => {
8
+ const srv = net.createServer();
9
+ srv.once('error', () => resolve(false));
10
+ srv.listen(port, () => { srv.close(() => resolve(true)); });
11
+ });
12
+ }
13
+ async function findFreePorts(startPort) {
14
+ for (let p = startPort; p < startPort + 100; p += 2) {
15
+ if (await isPortFree(p) && await isPortFree(p + 1)) {
16
+ return { port: p, wsPort: p + 1 };
17
+ }
18
+ }
19
+ throw new Error(`No free port pair found starting from ${startPort}`);
20
+ }
5
21
  function castlePlugin(wsPort) {
6
22
  return {
7
23
  name: 'castle-dev',
@@ -27,14 +43,24 @@ export async function serve(dir, options = {}) {
27
43
  console.error(`No index.html found in ${projectDir}`);
28
44
  process.exit(1);
29
45
  }
30
- const port = parseInt(options.port ?? '3737', 10);
31
- const wsPort = port + 1;
46
+ const startPort = parseInt(options.port ?? '3737', 10);
47
+ const { port, wsPort } = options.port
48
+ ? { port: startPort, wsPort: startPort + 1 }
49
+ : await findFreePorts(startPort);
32
50
  // Local WebSocket server for SDK communication
33
51
  const castleDir = path.join(projectDir, '.castle');
34
52
  if (!fs.existsSync(castleDir))
35
53
  fs.mkdirSync(castleDir, { recursive: true });
36
54
  const logFile = path.join(castleDir, 'logs.txt');
37
55
  const screenshotsDir = path.join(castleDir, 'screenshots');
56
+ const serveJsonPath = path.join(castleDir, 'serve.json');
57
+ fs.writeFileSync(serveJsonPath, JSON.stringify({ port, wsPort }) + '\n');
58
+ process.on('exit', () => { try {
59
+ fs.unlinkSync(serveJsonPath);
60
+ }
61
+ catch { } });
62
+ process.on('SIGINT', () => process.exit());
63
+ process.on('SIGTERM', () => process.exit());
38
64
  startWSServer(wsPort, logFile, screenshotsDir);
39
65
  const vite = await createServer({
40
66
  root: projectDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import * as fs from 'fs';
4
+ import * as path from 'path';
4
5
  import { login } from './login.js';
5
6
  import { serve } from './serve.js';
6
7
  import { push } from './push.js';
@@ -10,13 +11,48 @@ import { savePreviewImage, savePreviewIfNeeded } from './preview.js';
10
11
  const args = process.argv.slice(2);
11
12
  const command = args[0];
12
13
 
14
+ const FLAGS_WITH_VALUES = new Set(['--port', '--out']);
15
+
16
+ function findPositionalDir(): string {
17
+ for (let i = 1; i < args.length; i++) {
18
+ if (args[i].startsWith('--')) {
19
+ if (FLAGS_WITH_VALUES.has(args[i])) i++;
20
+ continue;
21
+ }
22
+ return args[i];
23
+ }
24
+ return '.';
25
+ }
26
+
27
+ function getFlagValue(flag: string): string | undefined {
28
+ const idx = args.indexOf(flag);
29
+ return idx >= 0 ? args[idx + 1] : undefined;
30
+ }
31
+
32
+ function readServeWsPort(dir: string): number | undefined {
33
+ try {
34
+ const serveJson = JSON.parse(fs.readFileSync(path.join(path.resolve(dir), '.castle', 'serve.json'), 'utf-8'));
35
+ return serveJson.wsPort;
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ }
40
+
41
+ function getWsPort(dir: string): number {
42
+ const explicit = getFlagValue('--port');
43
+ if (explicit) return parseInt(explicit, 10);
44
+ const fromServe = readServeWsPort(dir);
45
+ if (fromServe) return fromServe;
46
+ return 3738;
47
+ }
48
+
13
49
  function usage() {
14
50
  console.log(`Usage:
15
51
  castle-web init <dir>
16
52
  castle-web serve [dir] [--port PORT] [--open]
17
53
  castle-web restart [--port PORT]
18
54
  castle-web screenshot [--out FILE] [--port PORT]
19
- castle-web save-preview-image [dir] [--port PORT]
55
+ castle-web save-preview-image [dir] [--port PORT] [--no-restart]
20
56
  castle-web push [dir]
21
57
  castle-web login`);
22
58
  process.exit(1);
@@ -31,24 +67,22 @@ async function main() {
31
67
  break;
32
68
  }
33
69
  case 'serve': {
34
- const dir = args.find((a, i) => i > 0 && !a.startsWith('--')) ?? '.';
35
- const portIdx = args.indexOf('--port');
36
- const port = portIdx >= 0 ? args[portIdx + 1] : undefined;
70
+ const dir = findPositionalDir();
71
+ const port = getFlagValue('--port');
37
72
  const open = args.includes('--open');
38
73
  await serve(dir, { port, open });
39
74
  break;
40
75
  }
41
76
  case 'push': {
42
- const dir = args.find((a, i) => i > 0 && !a.startsWith('--')) ?? '.';
43
- const portIdx = args.indexOf('--port');
44
- const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
77
+ const dir = findPositionalDir();
45
78
  await push(dir);
79
+ const wsPort = getWsPort(dir);
46
80
  await savePreviewIfNeeded(dir, wsPort);
47
81
  break;
48
82
  }
49
83
  case 'restart': {
50
- const portIdx = args.indexOf('--port');
51
- const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
84
+ const dir = findPositionalDir();
85
+ const wsPort = getWsPort(dir);
52
86
  const { default: WS } = await import('ws');
53
87
  const ws = new WS(`ws://localhost:${wsPort}`);
54
88
  ws.on('open', () => {
@@ -59,10 +93,9 @@ async function main() {
59
93
  break;
60
94
  }
61
95
  case 'screenshot': {
62
- const outIdx = args.indexOf('--out');
63
- const outFile = outIdx >= 0 ? args[outIdx + 1] : 'screenshot.png';
64
- const portIdx = args.indexOf('--port');
65
- const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
96
+ const dir = findPositionalDir();
97
+ const outFile = getFlagValue('--out') ?? 'screenshot.png';
98
+ const wsPort = getWsPort(dir);
66
99
  const requestId = Math.random().toString(36).slice(2);
67
100
  const { default: WS } = await import('ws');
68
101
  const ws = new WS(`ws://localhost:${wsPort}`);
@@ -90,10 +123,10 @@ async function main() {
90
123
  break;
91
124
  }
92
125
  case 'save-preview-image': {
93
- const dir = args.find((a, i) => i > 0 && !a.startsWith('--')) ?? '.';
94
- const portIdx = args.indexOf('--port');
95
- const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
96
- await savePreviewImage(dir, wsPort);
126
+ const dir = findPositionalDir();
127
+ const wsPort = getWsPort(dir);
128
+ const noRestart = args.includes('--no-restart');
129
+ await savePreviewImage(dir, wsPort, noRestart);
97
130
  process.exit(0);
98
131
  }
99
132
  case 'login':
package/src/preview.ts CHANGED
@@ -17,7 +17,7 @@ function takeScreenshot(ws: any): Promise<string> {
17
17
  const requestId = Math.random().toString(36).slice(2);
18
18
  const timeout = setTimeout(() => {
19
19
  reject(new Error('Screenshot timed out.'));
20
- }, 5000);
20
+ }, 10000);
21
21
  ws.on('message', (raw: Buffer) => {
22
22
  try {
23
23
  const msg = JSON.parse(raw.toString());
@@ -33,13 +33,12 @@ function takeScreenshot(ws: any): Promise<string> {
33
33
 
34
34
  function waitForRestart(ws: any): Promise<void> {
35
35
  return new Promise((resolve) => {
36
- // restart, wait a moment for the deck to render its first frame
37
36
  ws.send(JSON.stringify({ type: 'restart' }));
38
- setTimeout(resolve, 500);
37
+ setTimeout(resolve, 2000);
39
38
  });
40
39
  }
41
40
 
42
- export async function savePreviewImage(dir: string, wsPort: number): Promise<void> {
41
+ export async function savePreviewImage(dir: string, wsPort: number, noRestart = false): Promise<void> {
43
42
  const projectDir = path.resolve(dir);
44
43
  const castleJsonPath = path.join(projectDir, 'castle.json');
45
44
  if (!fs.existsSync(castleJsonPath)) {
@@ -54,7 +53,7 @@ export async function savePreviewImage(dir: string, wsPort: number): Promise<voi
54
53
  }
55
54
 
56
55
  const ws = await connectWS(wsPort);
57
- await waitForRestart(ws);
56
+ if (!noRestart) await waitForRestart(ws);
58
57
  const base64 = await takeScreenshot(ws);
59
58
  ws.close();
60
59
 
@@ -71,8 +70,9 @@ export async function savePreviewIfNeeded(dir: string, wsPort: number): Promise<
71
70
  if (fs.existsSync(path.join(projectDir, 'preview.png'))) {
72
71
  return;
73
72
  }
73
+ let ws: any;
74
74
  try {
75
- const ws = await connectWS(wsPort);
75
+ ws = await connectWS(wsPort);
76
76
  await waitForRestart(ws);
77
77
  const base64 = await takeScreenshot(ws);
78
78
  ws.close();
@@ -87,7 +87,8 @@ export async function savePreviewIfNeeded(dir: string, wsPort: number): Promise<
87
87
  const file = await api.uploadBase64(base64, 'preview.png');
88
88
  await api.updateCardCustomBackgroundImage(castleJson.cardId, file.fileId);
89
89
  console.log('Set deck preview image.');
90
- } catch {
91
- // serve not running, skip preview
90
+ } catch (e: any) {
91
+ if (ws) ws.close();
92
+ console.log(`Preview skipped: ${e?.message ?? e}`);
92
93
  }
93
94
  }
package/src/serve.ts CHANGED
@@ -1,8 +1,26 @@
1
1
  import * as fs from 'fs';
2
+ import * as net from 'net';
2
3
  import * as path from 'path';
3
4
  import { createServer, type Plugin } from 'vite';
4
5
  import { WebSocketServer, WebSocket } from 'ws';
5
6
 
7
+ function isPortFree(port: number): Promise<boolean> {
8
+ return new Promise((resolve) => {
9
+ const srv = net.createServer();
10
+ srv.once('error', () => resolve(false));
11
+ srv.listen(port, () => { srv.close(() => resolve(true)); });
12
+ });
13
+ }
14
+
15
+ async function findFreePorts(startPort: number): Promise<{ port: number; wsPort: number }> {
16
+ for (let p = startPort; p < startPort + 100; p += 2) {
17
+ if (await isPortFree(p) && await isPortFree(p + 1)) {
18
+ return { port: p, wsPort: p + 1 };
19
+ }
20
+ }
21
+ throw new Error(`No free port pair found starting from ${startPort}`);
22
+ }
23
+
6
24
  function castlePlugin(wsPort: number): Plugin {
7
25
  return {
8
26
  name: 'castle-dev',
@@ -33,8 +51,10 @@ export async function serve(
33
51
  process.exit(1);
34
52
  }
35
53
 
36
- const port = parseInt(options.port ?? '3737', 10);
37
- const wsPort = port + 1;
54
+ const startPort = parseInt(options.port ?? '3737', 10);
55
+ const { port, wsPort } = options.port
56
+ ? { port: startPort, wsPort: startPort + 1 }
57
+ : await findFreePorts(startPort);
38
58
 
39
59
  // Local WebSocket server for SDK communication
40
60
  const castleDir = path.join(projectDir, '.castle');
@@ -42,6 +62,12 @@ export async function serve(
42
62
  const logFile = path.join(castleDir, 'logs.txt');
43
63
  const screenshotsDir = path.join(castleDir, 'screenshots');
44
64
 
65
+ const serveJsonPath = path.join(castleDir, 'serve.json');
66
+ fs.writeFileSync(serveJsonPath, JSON.stringify({ port, wsPort }) + '\n');
67
+ process.on('exit', () => { try { fs.unlinkSync(serveJsonPath); } catch {} });
68
+ process.on('SIGINT', () => process.exit());
69
+ process.on('SIGTERM', () => process.exit());
70
+
45
71
  startWSServer(wsPort, logFile, screenshotsDir);
46
72
 
47
73
  const vite = await createServer({