castle-web-cli 0.2.0 → 0.4.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/AGENTS.md CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  ## Commands
4
4
 
5
- - `castle-web init <dir>` — scaffold a new project (index.html, game.js, package.json with castle-web-sdk)
6
- - `castle-web serve [dir] [--port PORT] [--open]` — local dev server with Vite (default port 3737, WS on 3738). No auto-reload — use `restart` to reload.
7
- - `castle-web restart [--port PORT]` — reload the game in the browser (default WS port 3738)
8
- - `castle-web screenshot [--out FILE] [--port PORT]` — capture a screenshot from the running game (default: screenshot.png)
9
- - `castle-web push [dir]` — bundle with Vite and publish to Castle. Creates a new deck if no castle.json exists.
10
- - `castle-web login` — authenticate with Castle (saves token to ~/.castle/config.json)
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).
11
+ - `npx castle-web-cli login` — authenticate with Castle (saves token to ~/.castle/config.json)
11
12
 
12
13
  ## Project Structure
13
14
 
@@ -21,23 +22,3 @@ my-game/
21
22
  logs.txt # forwarded console.log/warn/error
22
23
  screenshots/ # captured screenshots
23
24
  ```
24
-
25
- ## SDK
26
-
27
- Games import and call `setup()` + `initCard()`:
28
-
29
- ```js
30
- import { setup, initCard } from 'castle-web-sdk';
31
- setup();
32
- const card = initCard();
33
- ```
34
-
35
- `setup()` enables log forwarding, screenshot capture, and live reload via local WebSocket. `initCard()` creates a 5:7 card container.
36
-
37
- ## Agent Workflow
38
-
39
- 1. `castle-web serve` in a background tmux window
40
- 2. Edit code
41
- 3. `castle-web restart` to reload
42
- 4. `castle-web screenshot --out shot.png` to see the result
43
- 5. Read `.castle/logs.txt` for console output
package/dist/api.d.ts CHANGED
@@ -18,3 +18,8 @@ export declare function uploadSceneData(cards: Array<{
18
18
  cardId: string;
19
19
  uploadId: string;
20
20
  }>): Promise<any[]>;
21
+ export declare function uploadBase64(base64: string, filename: string): Promise<{
22
+ fileId: string;
23
+ url: string;
24
+ }>;
25
+ export declare function updateCardCustomBackgroundImage(cardId: string, backgroundImageFileId: string): Promise<void>;
package/dist/api.js CHANGED
@@ -103,3 +103,18 @@ export async function uploadSceneData(cards) {
103
103
  handleAPIError(data);
104
104
  return data.data.uploadSceneData;
105
105
  }
106
+ export async function uploadBase64(base64, filename) {
107
+ const data = await graphql(`mutation($data: String!, $filename: String, $mimetype: String) {
108
+ uploadBase64(data: $data, filename: $filename, mimetype: $mimetype) {
109
+ fileId url
110
+ }
111
+ }`, { data: base64, filename, mimetype: 'image/png' });
112
+ handleAPIError(data);
113
+ return data.data.uploadBase64;
114
+ }
115
+ export async function updateCardCustomBackgroundImage(cardId, backgroundImageFileId) {
116
+ const data = await graphql(`mutation($cardId: ID!, $backgroundImageFileId: ID) {
117
+ updateCardCustomBackgroundImage(cardId: $cardId, backgroundImageFileId: $backgroundImageFileId)
118
+ }`, { cardId, backgroundImageFileId });
119
+ handleAPIError(data);
120
+ }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { login } from './login.js';
4
4
  import { serve } from './serve.js';
5
5
  import { push } from './push.js';
6
6
  import { init } from './init.js';
7
+ import { savePreviewImage, savePreviewIfNeeded } from './preview.js';
7
8
  const args = process.argv.slice(2);
8
9
  const command = args[0];
9
10
  function usage() {
@@ -12,10 +13,9 @@ function usage() {
12
13
  castle-web serve [dir] [--port PORT] [--open]
13
14
  castle-web restart [--port PORT]
14
15
  castle-web screenshot [--out FILE] [--port PORT]
16
+ castle-web save-preview-image [dir] [--port PORT]
15
17
  castle-web push [dir]
16
- castle-web login
17
-
18
- Install: cd castle-experimental-web/cli && npm install && npm run build && npm link`);
18
+ castle-web login`);
19
19
  process.exit(1);
20
20
  }
21
21
  async function main() {
@@ -39,7 +39,10 @@ async function main() {
39
39
  }
40
40
  case 'push': {
41
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);
42
44
  await push(dir);
45
+ await savePreviewIfNeeded(dir, wsPort);
43
46
  break;
44
47
  }
45
48
  case 'restart': {
@@ -86,6 +89,13 @@ async function main() {
86
89
  });
87
90
  break;
88
91
  }
92
+ 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);
97
+ process.exit(0);
98
+ }
89
99
  case 'login':
90
100
  await login();
91
101
  break;
package/dist/init.js CHANGED
@@ -25,6 +25,9 @@ el.style.cssText = \`
25
25
  el.textContent = 'Hello Castle!';
26
26
  card.appendChild(el);
27
27
  `;
28
+ const CLAUDE_MD = `@node_modules/castle-web-cli/AGENTS.md
29
+ @node_modules/castle-web-sdk/AGENTS.md
30
+ `;
28
31
  export async function init(dir) {
29
32
  const projectDir = path.resolve(dir);
30
33
  if (fs.existsSync(projectDir) && fs.readdirSync(projectDir).length > 0) {
@@ -34,7 +37,7 @@ export async function init(dir) {
34
37
  fs.mkdirSync(projectDir, { recursive: true });
35
38
  fs.writeFileSync(path.join(projectDir, 'index.html'), INDEX_HTML);
36
39
  fs.writeFileSync(path.join(projectDir, 'game.js'), GAME_JS);
37
- // Create package.json with SDK dependency
40
+ fs.writeFileSync(path.join(projectDir, 'CLAUDE.md'), CLAUDE_MD);
38
41
  const packageJson = {
39
42
  name: path.basename(projectDir),
40
43
  private: true,
@@ -42,16 +45,18 @@ export async function init(dir) {
42
45
  dependencies: {
43
46
  'castle-web-sdk': '*',
44
47
  },
48
+ devDependencies: {
49
+ 'castle-web-cli': '*',
50
+ },
45
51
  };
46
52
  fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify(packageJson, null, 2) + '\n');
47
- // Install dependencies (picks up npm-linked SDK)
48
53
  const { execSync } = await import('child_process');
49
54
  execSync('npm install', { cwd: projectDir, stdio: 'inherit' });
50
55
  console.log(`Created project in ${projectDir}/`);
51
56
  console.log('');
52
57
  console.log('Next steps:');
53
58
  console.log(` cd ${dir}`);
54
- console.log(' castle-web serve --open');
55
- console.log(' castle-web login');
56
- console.log(' castle-web push');
59
+ console.log(' npx castle-web-cli serve --open');
60
+ console.log(' npx castle-web-cli login');
61
+ console.log(' npx castle-web-cli push');
57
62
  }
@@ -0,0 +1,2 @@
1
+ export declare function savePreviewImage(dir: string, wsPort: number): Promise<void>;
2
+ export declare function savePreviewIfNeeded(dir: string, wsPort: number): Promise<void>;
@@ -0,0 +1,84 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as api from './api.js';
4
+ import * as config from './config.js';
5
+ function connectWS(wsPort) {
6
+ return new Promise(async (resolve, reject) => {
7
+ const { default: WS } = await import('ws');
8
+ const ws = new WS(`ws://localhost:${wsPort}`);
9
+ ws.on('error', () => reject(new Error('Could not connect. Is castle-web serve running?')));
10
+ ws.on('open', () => resolve(ws));
11
+ });
12
+ }
13
+ function takeScreenshot(ws) {
14
+ return new Promise((resolve, reject) => {
15
+ const requestId = Math.random().toString(36).slice(2);
16
+ const timeout = setTimeout(() => {
17
+ reject(new Error('Screenshot timed out.'));
18
+ }, 5000);
19
+ ws.on('message', (raw) => {
20
+ try {
21
+ const msg = JSON.parse(raw.toString());
22
+ if (msg.type === 'screenshot_response' && msg.requestId === requestId) {
23
+ clearTimeout(timeout);
24
+ resolve(msg.data.replace(/^data:image\/png;base64,/, ''));
25
+ }
26
+ }
27
+ catch { }
28
+ });
29
+ ws.send(JSON.stringify({ type: 'screenshot_request', requestId }));
30
+ });
31
+ }
32
+ function waitForRestart(ws) {
33
+ return new Promise((resolve) => {
34
+ // restart, wait a moment for the deck to render its first frame
35
+ ws.send(JSON.stringify({ type: 'restart' }));
36
+ setTimeout(resolve, 500);
37
+ });
38
+ }
39
+ export async function savePreviewImage(dir, wsPort) {
40
+ const projectDir = path.resolve(dir);
41
+ const castleJsonPath = path.join(projectDir, 'castle.json');
42
+ if (!fs.existsSync(castleJsonPath)) {
43
+ console.error('No castle.json found. Push the deck first.');
44
+ process.exit(1);
45
+ }
46
+ const castleJson = JSON.parse(fs.readFileSync(castleJsonPath, 'utf-8'));
47
+ if (!config.getToken()) {
48
+ console.error('Not logged in. Run `castle-web login` first.');
49
+ process.exit(1);
50
+ }
51
+ const ws = await connectWS(wsPort);
52
+ await waitForRestart(ws);
53
+ const base64 = await takeScreenshot(ws);
54
+ ws.close();
55
+ fs.writeFileSync(path.join(projectDir, 'preview.png'), Buffer.from(base64, 'base64'));
56
+ console.log('Saved preview.png');
57
+ const file = await api.uploadBase64(base64, 'preview.png');
58
+ await api.updateCardCustomBackgroundImage(castleJson.cardId, file.fileId);
59
+ console.log('Set deck preview image.');
60
+ }
61
+ export async function savePreviewIfNeeded(dir, wsPort) {
62
+ const projectDir = path.resolve(dir);
63
+ if (fs.existsSync(path.join(projectDir, 'preview.png'))) {
64
+ return;
65
+ }
66
+ try {
67
+ const ws = await connectWS(wsPort);
68
+ await waitForRestart(ws);
69
+ const base64 = await takeScreenshot(ws);
70
+ ws.close();
71
+ const castleJsonPath = path.join(projectDir, 'castle.json');
72
+ if (!fs.existsSync(castleJsonPath))
73
+ return;
74
+ const castleJson = JSON.parse(fs.readFileSync(castleJsonPath, 'utf-8'));
75
+ fs.writeFileSync(path.join(projectDir, 'preview.png'), Buffer.from(base64, 'base64'));
76
+ console.log('Saved preview.png');
77
+ const file = await api.uploadBase64(base64, 'preview.png');
78
+ await api.updateCardCustomBackgroundImage(castleJson.cardId, file.fileId);
79
+ console.log('Set deck preview image.');
80
+ }
81
+ catch {
82
+ // serve not running, skip preview
83
+ }
84
+ }
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,8 +43,10 @@ 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))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"
package/src/api.ts CHANGED
@@ -135,3 +135,26 @@ export async function uploadSceneData(
135
135
  handleAPIError(data);
136
136
  return data.data.uploadSceneData;
137
137
  }
138
+
139
+ export async function uploadBase64(base64: string, filename: string): Promise<{ fileId: string; url: string }> {
140
+ const data = await graphql(
141
+ `mutation($data: String!, $filename: String, $mimetype: String) {
142
+ uploadBase64(data: $data, filename: $filename, mimetype: $mimetype) {
143
+ fileId url
144
+ }
145
+ }`,
146
+ { data: base64, filename, mimetype: 'image/png' }
147
+ );
148
+ handleAPIError(data);
149
+ return data.data.uploadBase64;
150
+ }
151
+
152
+ export async function updateCardCustomBackgroundImage(cardId: string, backgroundImageFileId: string): Promise<void> {
153
+ const data = await graphql(
154
+ `mutation($cardId: ID!, $backgroundImageFileId: ID) {
155
+ updateCardCustomBackgroundImage(cardId: $cardId, backgroundImageFileId: $backgroundImageFileId)
156
+ }`,
157
+ { cardId, backgroundImageFileId }
158
+ );
159
+ handleAPIError(data);
160
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { login } from './login.js';
5
5
  import { serve } from './serve.js';
6
6
  import { push } from './push.js';
7
7
  import { init } from './init.js';
8
+ import { savePreviewImage, savePreviewIfNeeded } from './preview.js';
8
9
 
9
10
  const args = process.argv.slice(2);
10
11
  const command = args[0];
@@ -15,10 +16,9 @@ function usage() {
15
16
  castle-web serve [dir] [--port PORT] [--open]
16
17
  castle-web restart [--port PORT]
17
18
  castle-web screenshot [--out FILE] [--port PORT]
19
+ castle-web save-preview-image [dir] [--port PORT]
18
20
  castle-web push [dir]
19
- castle-web login
20
-
21
- Install: cd castle-experimental-web/cli && npm install && npm run build && npm link`);
21
+ castle-web login`);
22
22
  process.exit(1);
23
23
  }
24
24
 
@@ -40,7 +40,10 @@ async function main() {
40
40
  }
41
41
  case 'push': {
42
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);
43
45
  await push(dir);
46
+ await savePreviewIfNeeded(dir, wsPort);
44
47
  break;
45
48
  }
46
49
  case 'restart': {
@@ -86,6 +89,13 @@ async function main() {
86
89
  });
87
90
  break;
88
91
  }
92
+ 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);
97
+ process.exit(0);
98
+ }
89
99
  case 'login':
90
100
  await login();
91
101
  break;
package/src/init.ts CHANGED
@@ -28,6 +28,10 @@ el.textContent = 'Hello Castle!';
28
28
  card.appendChild(el);
29
29
  `;
30
30
 
31
+ const CLAUDE_MD = `@node_modules/castle-web-cli/AGENTS.md
32
+ @node_modules/castle-web-sdk/AGENTS.md
33
+ `;
34
+
31
35
  export async function init(dir: string) {
32
36
  const projectDir = path.resolve(dir);
33
37
 
@@ -39,8 +43,8 @@ export async function init(dir: string) {
39
43
  fs.mkdirSync(projectDir, { recursive: true });
40
44
  fs.writeFileSync(path.join(projectDir, 'index.html'), INDEX_HTML);
41
45
  fs.writeFileSync(path.join(projectDir, 'game.js'), GAME_JS);
46
+ fs.writeFileSync(path.join(projectDir, 'CLAUDE.md'), CLAUDE_MD);
42
47
 
43
- // Create package.json with SDK dependency
44
48
  const packageJson = {
45
49
  name: path.basename(projectDir),
46
50
  private: true,
@@ -48,10 +52,12 @@ export async function init(dir: string) {
48
52
  dependencies: {
49
53
  'castle-web-sdk': '*',
50
54
  },
55
+ devDependencies: {
56
+ 'castle-web-cli': '*',
57
+ },
51
58
  };
52
59
  fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify(packageJson, null, 2) + '\n');
53
60
 
54
- // Install dependencies (picks up npm-linked SDK)
55
61
  const { execSync } = await import('child_process');
56
62
  execSync('npm install', { cwd: projectDir, stdio: 'inherit' });
57
63
 
@@ -59,7 +65,7 @@ export async function init(dir: string) {
59
65
  console.log('');
60
66
  console.log('Next steps:');
61
67
  console.log(` cd ${dir}`);
62
- console.log(' castle-web serve --open');
63
- console.log(' castle-web login');
64
- console.log(' castle-web push');
68
+ console.log(' npx castle-web-cli serve --open');
69
+ console.log(' npx castle-web-cli login');
70
+ console.log(' npx castle-web-cli push');
65
71
  }
package/src/preview.ts ADDED
@@ -0,0 +1,93 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as api from './api.js';
4
+ import * as config from './config.js';
5
+
6
+ function connectWS(wsPort: number): Promise<any> {
7
+ return new Promise(async (resolve, reject) => {
8
+ const { default: WS } = await import('ws');
9
+ const ws = new WS(`ws://localhost:${wsPort}`);
10
+ ws.on('error', () => reject(new Error('Could not connect. Is castle-web serve running?')));
11
+ ws.on('open', () => resolve(ws));
12
+ });
13
+ }
14
+
15
+ function takeScreenshot(ws: any): Promise<string> {
16
+ return new Promise((resolve, reject) => {
17
+ const requestId = Math.random().toString(36).slice(2);
18
+ const timeout = setTimeout(() => {
19
+ reject(new Error('Screenshot timed out.'));
20
+ }, 5000);
21
+ ws.on('message', (raw: Buffer) => {
22
+ try {
23
+ const msg = JSON.parse(raw.toString());
24
+ if (msg.type === 'screenshot_response' && msg.requestId === requestId) {
25
+ clearTimeout(timeout);
26
+ resolve(msg.data.replace(/^data:image\/png;base64,/, ''));
27
+ }
28
+ } catch {}
29
+ });
30
+ ws.send(JSON.stringify({ type: 'screenshot_request', requestId }));
31
+ });
32
+ }
33
+
34
+ function waitForRestart(ws: any): Promise<void> {
35
+ return new Promise((resolve) => {
36
+ // restart, wait a moment for the deck to render its first frame
37
+ ws.send(JSON.stringify({ type: 'restart' }));
38
+ setTimeout(resolve, 500);
39
+ });
40
+ }
41
+
42
+ export async function savePreviewImage(dir: string, wsPort: number): Promise<void> {
43
+ const projectDir = path.resolve(dir);
44
+ const castleJsonPath = path.join(projectDir, 'castle.json');
45
+ if (!fs.existsSync(castleJsonPath)) {
46
+ console.error('No castle.json found. Push the deck first.');
47
+ process.exit(1);
48
+ }
49
+ const castleJson = JSON.parse(fs.readFileSync(castleJsonPath, 'utf-8'));
50
+
51
+ if (!config.getToken()) {
52
+ console.error('Not logged in. Run `castle-web login` first.');
53
+ process.exit(1);
54
+ }
55
+
56
+ const ws = await connectWS(wsPort);
57
+ await waitForRestart(ws);
58
+ const base64 = await takeScreenshot(ws);
59
+ ws.close();
60
+
61
+ fs.writeFileSync(path.join(projectDir, 'preview.png'), Buffer.from(base64, 'base64'));
62
+ console.log('Saved preview.png');
63
+
64
+ const file = await api.uploadBase64(base64, 'preview.png');
65
+ await api.updateCardCustomBackgroundImage(castleJson.cardId, file.fileId);
66
+ console.log('Set deck preview image.');
67
+ }
68
+
69
+ export async function savePreviewIfNeeded(dir: string, wsPort: number): Promise<void> {
70
+ const projectDir = path.resolve(dir);
71
+ if (fs.existsSync(path.join(projectDir, 'preview.png'))) {
72
+ return;
73
+ }
74
+ try {
75
+ const ws = await connectWS(wsPort);
76
+ await waitForRestart(ws);
77
+ const base64 = await takeScreenshot(ws);
78
+ ws.close();
79
+
80
+ const castleJsonPath = path.join(projectDir, 'castle.json');
81
+ if (!fs.existsSync(castleJsonPath)) return;
82
+ const castleJson = JSON.parse(fs.readFileSync(castleJsonPath, 'utf-8'));
83
+
84
+ fs.writeFileSync(path.join(projectDir, 'preview.png'), Buffer.from(base64, 'base64'));
85
+ console.log('Saved preview.png');
86
+
87
+ const file = await api.uploadBase64(base64, 'preview.png');
88
+ await api.updateCardCustomBackgroundImage(castleJson.cardId, file.fileId);
89
+ console.log('Set deck preview image.');
90
+ } catch {
91
+ // serve not running, skip preview
92
+ }
93
+ }
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');