castle-web-cli 0.1.0 → 0.3.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 +11 -9
- package/dist/api.d.ts +5 -0
- package/dist/api.js +16 -1
- package/dist/bundle.d.ts +1 -1
- package/dist/bundle.js +21 -23
- package/dist/index.js +60 -3
- package/dist/init.js +17 -12
- package/dist/preview.d.ts +2 -0
- package/dist/preview.js +84 -0
- package/dist/push.js +7 -3
- package/dist/serve.js +81 -104
- package/package.json +6 -3
- package/src/api.ts +24 -1
- package/src/bundle.ts +21 -28
- package/src/index.ts +59 -3
- package/src/init.ts +18 -12
- package/src/preview.ts +93 -0
- package/src/push.ts +7 -3
- package/src/serve.ts +77 -104
package/AGENTS.md
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## Commands
|
|
4
4
|
|
|
5
|
-
- `castle-web init <dir>` — scaffold a new project (index.html, game.js,
|
|
6
|
-
- `castle-web serve [dir] [--port PORT] [--open]` — local dev server with
|
|
7
|
-
- `castle-web
|
|
8
|
-
- `castle-web
|
|
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)
|
|
9
12
|
|
|
10
13
|
## Project Structure
|
|
11
14
|
|
|
@@ -13,10 +16,9 @@
|
|
|
13
16
|
my-game/
|
|
14
17
|
index.html # entry point
|
|
15
18
|
game.js # game code
|
|
16
|
-
|
|
19
|
+
package.json # dependencies (castle-web-sdk)
|
|
17
20
|
castle.json # deck/card IDs (created by first push)
|
|
21
|
+
.castle/ # runtime state (logs, screenshots)
|
|
22
|
+
logs.txt # forwarded console.log/warn/error
|
|
23
|
+
screenshots/ # captured screenshots
|
|
18
24
|
```
|
|
19
|
-
|
|
20
|
-
## Bundling
|
|
21
|
-
|
|
22
|
-
`push` uses esbuild to inline all `<script src="...">` tags into a single self-contained HTML string stored in the deck's scene data as `experimentalWeb.bundle`.
|
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
|
@@ -41,7 +41,7 @@ export async function pollForCLILogin(pollToken) {
|
|
|
41
41
|
}
|
|
42
42
|
export async function me() {
|
|
43
43
|
try {
|
|
44
|
-
const data = await graphql(`query { me { userId username isAnonymous } }`);
|
|
44
|
+
const data = await graphql(`query { me { userId username isAnonymous photo { url } photoFrame { frameUrl } } }`);
|
|
45
45
|
return data.data?.me ?? null;
|
|
46
46
|
}
|
|
47
47
|
catch {
|
|
@@ -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/bundle.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function bundleProject(dir: string): string
|
|
1
|
+
export declare function bundleProject(dir: string): Promise<string>;
|
package/dist/bundle.js
CHANGED
|
@@ -1,27 +1,25 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
1
|
import * as path from 'path';
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
// Find <script src="..."> tags, bundle each with esbuild, inline the result
|
|
11
|
-
html = html.replace(/<script\s+([^>]*?)src=["']([^"']+)["']([^>]*?)>\s*<\/script>/gi, (fullMatch, before, src, after) => {
|
|
12
|
-
const srcPath = path.resolve(dir, src);
|
|
13
|
-
if (!fs.existsSync(srcPath))
|
|
14
|
-
return fullMatch;
|
|
15
|
-
const result = esbuild.buildSync({
|
|
16
|
-
entryPoints: [srcPath],
|
|
17
|
-
bundle: true,
|
|
18
|
-
format: 'esm',
|
|
2
|
+
import { build } from 'vite';
|
|
3
|
+
import { viteSingleFile } from 'vite-plugin-singlefile';
|
|
4
|
+
export async function bundleProject(dir) {
|
|
5
|
+
const result = await build({
|
|
6
|
+
root: dir,
|
|
7
|
+
plugins: [viteSingleFile()],
|
|
8
|
+
build: {
|
|
19
9
|
write: false,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
10
|
+
rollupOptions: {
|
|
11
|
+
input: path.join(dir, 'index.html'),
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
logLevel: 'silent',
|
|
25
15
|
});
|
|
26
|
-
|
|
16
|
+
const output = Array.isArray(result) ? result[0] : result;
|
|
17
|
+
if (!('output' in output))
|
|
18
|
+
throw new Error('Unexpected build result');
|
|
19
|
+
for (const chunk of output.output) {
|
|
20
|
+
if (chunk.type === 'asset' && chunk.fileName.endsWith('.html')) {
|
|
21
|
+
return chunk.source;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
throw new Error('No HTML output found from build');
|
|
27
25
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'fs';
|
|
2
3
|
import { login } from './login.js';
|
|
3
4
|
import { serve } from './serve.js';
|
|
4
5
|
import { push } from './push.js';
|
|
5
6
|
import { init } from './init.js';
|
|
7
|
+
import { savePreviewImage, savePreviewIfNeeded } from './preview.js';
|
|
6
8
|
const args = process.argv.slice(2);
|
|
7
9
|
const command = args[0];
|
|
8
10
|
function usage() {
|
|
9
11
|
console.log(`Usage:
|
|
10
12
|
castle-web init <dir>
|
|
11
13
|
castle-web serve [dir] [--port PORT] [--open]
|
|
14
|
+
castle-web restart [--port PORT]
|
|
15
|
+
castle-web screenshot [--out FILE] [--port PORT]
|
|
16
|
+
castle-web save-preview-image [dir] [--port PORT]
|
|
12
17
|
castle-web push [dir]
|
|
13
|
-
castle-web login
|
|
14
|
-
|
|
15
|
-
Install: cd castle-experimental-web/cli && npm install && npm run build && npm link`);
|
|
18
|
+
castle-web login`);
|
|
16
19
|
process.exit(1);
|
|
17
20
|
}
|
|
18
21
|
async function main() {
|
|
@@ -36,9 +39,63 @@ async function main() {
|
|
|
36
39
|
}
|
|
37
40
|
case 'push': {
|
|
38
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);
|
|
39
44
|
await push(dir);
|
|
45
|
+
await savePreviewIfNeeded(dir, wsPort);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
case 'restart': {
|
|
49
|
+
const portIdx = args.indexOf('--port');
|
|
50
|
+
const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
|
|
51
|
+
const { default: WS } = await import('ws');
|
|
52
|
+
const ws = new WS(`ws://localhost:${wsPort}`);
|
|
53
|
+
ws.on('open', () => {
|
|
54
|
+
ws.send(JSON.stringify({ type: 'restart' }));
|
|
55
|
+
setTimeout(() => { ws.close(); process.exit(0); }, 100);
|
|
56
|
+
});
|
|
57
|
+
ws.on('error', () => { console.error('Could not connect. Is castle-web serve running?'); process.exit(1); });
|
|
40
58
|
break;
|
|
41
59
|
}
|
|
60
|
+
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);
|
|
65
|
+
const requestId = Math.random().toString(36).slice(2);
|
|
66
|
+
const { default: WS } = await import('ws');
|
|
67
|
+
const ws = new WS(`ws://localhost:${wsPort}`);
|
|
68
|
+
ws.on('open', () => {
|
|
69
|
+
ws.send(JSON.stringify({ type: 'screenshot_request', requestId }));
|
|
70
|
+
});
|
|
71
|
+
const timeout = setTimeout(() => {
|
|
72
|
+
console.error('Screenshot timed out. Is castle-web serve running?');
|
|
73
|
+
ws.close();
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}, 3000);
|
|
76
|
+
ws.on('message', (raw) => {
|
|
77
|
+
try {
|
|
78
|
+
const msg = JSON.parse(raw.toString());
|
|
79
|
+
if (msg.type === 'screenshot_response' && msg.requestId === requestId) {
|
|
80
|
+
clearTimeout(timeout);
|
|
81
|
+
const base64 = msg.data.replace(/^data:image\/png;base64,/, '');
|
|
82
|
+
fs.writeFileSync(outFile, Buffer.from(base64, 'base64'));
|
|
83
|
+
console.log(`Saved ${outFile}`);
|
|
84
|
+
ws.close();
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
});
|
|
90
|
+
break;
|
|
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
|
+
}
|
|
42
99
|
case 'login':
|
|
43
100
|
await login();
|
|
44
101
|
break;
|
package/dist/init.js
CHANGED
|
@@ -11,19 +11,22 @@ const INDEX_HTML = `<!DOCTYPE html>
|
|
|
11
11
|
</body>
|
|
12
12
|
</html>
|
|
13
13
|
`;
|
|
14
|
-
const GAME_JS = `import
|
|
14
|
+
const GAME_JS = `import { setup, initCard } from 'castle-web-sdk';
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
const card =
|
|
16
|
+
setup();
|
|
17
|
+
const card = initCard();
|
|
18
18
|
|
|
19
|
-
const
|
|
20
|
-
|
|
19
|
+
const el = document.createElement('div');
|
|
20
|
+
el.style.cssText = \`
|
|
21
21
|
width: 100%; height: 100%;
|
|
22
22
|
display: flex; align-items: center; justify-content: center;
|
|
23
23
|
font-family: system-ui; color: #fff; font-size: 24px;
|
|
24
24
|
\`;
|
|
25
|
-
|
|
26
|
-
card.appendChild(
|
|
25
|
+
el.textContent = 'Hello Castle!';
|
|
26
|
+
card.appendChild(el);
|
|
27
|
+
`;
|
|
28
|
+
const CLAUDE_MD = `@node_modules/castle-web-cli/AGENTS.md
|
|
29
|
+
@node_modules/castle-web-sdk/AGENTS.md
|
|
27
30
|
`;
|
|
28
31
|
export async function init(dir) {
|
|
29
32
|
const projectDir = path.resolve(dir);
|
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/preview.js
ADDED
|
@@ -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/push.js
CHANGED
|
@@ -39,8 +39,12 @@ export async function push(dir) {
|
|
|
39
39
|
}
|
|
40
40
|
let castleJson = readCastleJson(projectDir);
|
|
41
41
|
console.log('Bundling...');
|
|
42
|
-
const bundle = bundleProject(projectDir);
|
|
43
|
-
|
|
42
|
+
const bundle = await bundleProject(projectDir);
|
|
43
|
+
const sizeKB = bundle.length / 1024;
|
|
44
|
+
console.log(`Bundle size: ${sizeKB.toFixed(1)}KB`);
|
|
45
|
+
if (sizeKB > 1024) {
|
|
46
|
+
console.warn(`Warning: Bundle is ${(sizeKB / 1024).toFixed(1)}MB. Large bundles may load slowly.`);
|
|
47
|
+
}
|
|
44
48
|
const sceneData = buildSceneData(bundle);
|
|
45
49
|
const isNew = !castleJson;
|
|
46
50
|
const cardId = castleJson?.cardId ?? nanoid(12);
|
|
@@ -69,7 +73,7 @@ export async function push(dir) {
|
|
|
69
73
|
// Create or update via updateCardAndDeckV2
|
|
70
74
|
const title = path.basename(path.resolve(projectDir));
|
|
71
75
|
try {
|
|
72
|
-
const result = await api.updateCardAndDeckV2({ deckId, title }, { cardId, blocks: [], uploadId: uploadConfig.uploadId, makeInitialCard: true });
|
|
76
|
+
const result = await api.updateCardAndDeckV2({ deckId, title, visibility: 'unlisted' }, { cardId, blocks: [], uploadId: uploadConfig.uploadId, makeInitialCard: true });
|
|
73
77
|
if (isNew) {
|
|
74
78
|
const newCastleJson = { deckId: result.deckId, cardId: result.cardId };
|
|
75
79
|
fs.writeFileSync(path.join(projectDir, 'castle.json'), JSON.stringify(newCastleJson, null, 2) + '\n', 'utf-8');
|
package/dist/serve.js
CHANGED
|
@@ -1,120 +1,97 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'.webm': 'video/webm',
|
|
21
|
-
'.ttf': 'font/ttf',
|
|
22
|
-
'.woff': 'font/woff',
|
|
23
|
-
'.woff2': 'font/woff2',
|
|
24
|
-
};
|
|
3
|
+
import { createServer } from 'vite';
|
|
4
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
|
+
function castlePlugin(wsPort) {
|
|
6
|
+
return {
|
|
7
|
+
name: 'castle-dev',
|
|
8
|
+
configureServer(server) {
|
|
9
|
+
server.middlewares.use((req, res, next) => {
|
|
10
|
+
if (req.url === '/__castle/ws-port') {
|
|
11
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
12
|
+
res.end(JSON.stringify({ port: wsPort }));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
next();
|
|
16
|
+
});
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
25
20
|
export async function serve(dir, options = {}) {
|
|
26
21
|
const projectDir = path.resolve(dir);
|
|
27
22
|
if (!fs.existsSync(projectDir)) {
|
|
28
23
|
console.error(`Directory not found: ${projectDir}`);
|
|
29
24
|
process.exit(1);
|
|
30
25
|
}
|
|
31
|
-
|
|
32
|
-
if (!fs.existsSync(indexPath)) {
|
|
26
|
+
if (!fs.existsSync(path.join(projectDir, 'index.html'))) {
|
|
33
27
|
console.error(`No index.html found in ${projectDir}`);
|
|
34
28
|
process.exit(1);
|
|
35
29
|
}
|
|
36
30
|
const port = parseInt(options.port ?? '3737', 10);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
31
|
+
const wsPort = port + 1;
|
|
32
|
+
// Local WebSocket server for SDK communication
|
|
33
|
+
const castleDir = path.join(projectDir, '.castle');
|
|
34
|
+
if (!fs.existsSync(castleDir))
|
|
35
|
+
fs.mkdirSync(castleDir, { recursive: true });
|
|
36
|
+
const logFile = path.join(castleDir, 'logs.txt');
|
|
37
|
+
const screenshotsDir = path.join(castleDir, 'screenshots');
|
|
38
|
+
startWSServer(wsPort, logFile, screenshotsDir);
|
|
39
|
+
const vite = await createServer({
|
|
40
|
+
root: projectDir,
|
|
41
|
+
plugins: [castlePlugin(wsPort)],
|
|
42
|
+
server: {
|
|
43
|
+
port,
|
|
44
|
+
strictPort: true,
|
|
45
|
+
open: options.open ? true : undefined,
|
|
46
|
+
hmr: false,
|
|
47
|
+
fs: { strict: false },
|
|
48
|
+
},
|
|
49
|
+
logLevel: 'info',
|
|
52
50
|
});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
res.end('Not found');
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
const stat = fs.statSync(filePath);
|
|
97
|
-
if (stat.isDirectory()) {
|
|
98
|
-
filePath = path.join(filePath, 'index.html');
|
|
99
|
-
if (!fs.existsSync(filePath)) {
|
|
100
|
-
res.writeHead(404);
|
|
101
|
-
res.end('Not found');
|
|
102
|
-
return;
|
|
51
|
+
await vite.listen();
|
|
52
|
+
vite.printUrls();
|
|
53
|
+
}
|
|
54
|
+
function startWSServer(port, logFile, screenshotsDir) {
|
|
55
|
+
const wss = new WebSocketServer({ port });
|
|
56
|
+
const clients = new Set();
|
|
57
|
+
wss.on('connection', (ws) => {
|
|
58
|
+
clients.add(ws);
|
|
59
|
+
ws.on('message', (raw) => {
|
|
60
|
+
try {
|
|
61
|
+
const msg = JSON.parse(raw.toString());
|
|
62
|
+
if (msg.type === 'restart' || msg.type === 'screenshot_request') {
|
|
63
|
+
const fwd = JSON.stringify(msg);
|
|
64
|
+
for (const c of clients) {
|
|
65
|
+
if (c !== ws && c.readyState === WebSocket.OPEN)
|
|
66
|
+
c.send(fwd);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (msg.type === 'log') {
|
|
70
|
+
const line = `[${msg.level}] ${msg.msg}\n`;
|
|
71
|
+
fs.appendFileSync(logFile, line);
|
|
72
|
+
if (msg.level === 'error')
|
|
73
|
+
process.stderr.write(line);
|
|
74
|
+
else
|
|
75
|
+
process.stdout.write(line);
|
|
76
|
+
}
|
|
77
|
+
if (msg.type === 'screenshot_response') {
|
|
78
|
+
if (!fs.existsSync(screenshotsDir))
|
|
79
|
+
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
80
|
+
const filename = `screenshot-${Date.now()}.png`;
|
|
81
|
+
const filepath = path.join(screenshotsDir, filename);
|
|
82
|
+
const base64 = msg.data.replace(/^data:image\/png;base64,/, '');
|
|
83
|
+
fs.writeFileSync(filepath, Buffer.from(base64, 'base64'));
|
|
84
|
+
console.log(`Screenshot saved: ${filepath}`);
|
|
85
|
+
const fwd = JSON.stringify(msg);
|
|
86
|
+
for (const c of clients) {
|
|
87
|
+
if (c !== ws && c.readyState === WebSocket.OPEN)
|
|
88
|
+
c.send(fwd);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
103
91
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const data = fs.readFileSync(filePath);
|
|
108
|
-
res.writeHead(200, { 'Content-Type': contentType });
|
|
109
|
-
res.end(data);
|
|
110
|
-
});
|
|
111
|
-
server.listen(port, () => {
|
|
112
|
-
const url = `http://localhost:${port}`;
|
|
113
|
-
console.log(`Serving ${projectDir}`);
|
|
114
|
-
console.log(` ${url}`);
|
|
115
|
-
console.log('Press Ctrl+C to stop.');
|
|
116
|
-
if (options.open) {
|
|
117
|
-
import('open').then(({ default: open }) => open(url));
|
|
118
|
-
}
|
|
92
|
+
catch { }
|
|
93
|
+
});
|
|
94
|
+
ws.on('close', () => { clients.delete(ws); });
|
|
119
95
|
});
|
|
96
|
+
return wss;
|
|
120
97
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "castle-web-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"castle-web": "./dist/index.js"
|
|
@@ -10,12 +10,15 @@
|
|
|
10
10
|
"dev": "tsc --watch"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"esbuild": "^0.28.0",
|
|
14
13
|
"nanoid": "^5.1.7",
|
|
15
|
-
"open": "^10.0.0"
|
|
14
|
+
"open": "^10.0.0",
|
|
15
|
+
"vite": "^8.0.3",
|
|
16
|
+
"vite-plugin-singlefile": "^2.3.2",
|
|
17
|
+
"ws": "^8.20.0"
|
|
16
18
|
},
|
|
17
19
|
"devDependencies": {
|
|
18
20
|
"@types/node": "^20.0.0",
|
|
21
|
+
"@types/ws": "^8.18.1",
|
|
19
22
|
"typescript": "^5.0.0"
|
|
20
23
|
}
|
|
21
24
|
}
|
package/src/api.ts
CHANGED
|
@@ -49,7 +49,7 @@ export async function pollForCLILogin(pollToken: string): Promise<any> {
|
|
|
49
49
|
|
|
50
50
|
export async function me(): Promise<any> {
|
|
51
51
|
try {
|
|
52
|
-
const data = await graphql(`query { me { userId username isAnonymous } }`);
|
|
52
|
+
const data = await graphql(`query { me { userId username isAnonymous photo { url } photoFrame { frameUrl } } }`);
|
|
53
53
|
return data.data?.me ?? null;
|
|
54
54
|
} catch {
|
|
55
55
|
return null;
|
|
@@ -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/bundle.ts
CHANGED
|
@@ -1,35 +1,28 @@
|
|
|
1
|
-
import * as fs from 'fs';
|
|
2
1
|
import * as path from 'path';
|
|
3
|
-
import
|
|
2
|
+
import { build } from 'vite';
|
|
3
|
+
import { viteSingleFile } from 'vite-plugin-singlefile';
|
|
4
4
|
|
|
5
|
-
export function bundleProject(dir: string): string {
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
export async function bundleProject(dir: string): Promise<string> {
|
|
6
|
+
const result = await build({
|
|
7
|
+
root: dir,
|
|
8
|
+
plugins: [viteSingleFile()],
|
|
9
|
+
build: {
|
|
10
|
+
write: false,
|
|
11
|
+
rollupOptions: {
|
|
12
|
+
input: path.join(dir, 'index.html'),
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
logLevel: 'silent',
|
|
16
|
+
});
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/<script\s+([^>]*?)src=["']([^"']+)["']([^>]*?)>\s*<\/script>/gi,
|
|
16
|
-
(fullMatch, before, src, after) => {
|
|
17
|
-
const srcPath = path.resolve(dir, src);
|
|
18
|
-
if (!fs.existsSync(srcPath)) return fullMatch;
|
|
18
|
+
const output = Array.isArray(result) ? result[0] : result;
|
|
19
|
+
if (!('output' in output)) throw new Error('Unexpected build result');
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
format: 'esm',
|
|
24
|
-
write: false,
|
|
25
|
-
minify: false,
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const code = result.outputFiles[0].text;
|
|
29
|
-
const attrs = `${before}${after}`.trim();
|
|
30
|
-
return `<script type="module"${attrs ? ' ' + attrs : ''}>\n${code}</script>`;
|
|
21
|
+
for (const chunk of output.output) {
|
|
22
|
+
if (chunk.type === 'asset' && chunk.fileName.endsWith('.html')) {
|
|
23
|
+
return chunk.source as string;
|
|
31
24
|
}
|
|
32
|
-
|
|
25
|
+
}
|
|
33
26
|
|
|
34
|
-
|
|
27
|
+
throw new Error('No HTML output found from build');
|
|
35
28
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import * as fs from 'fs';
|
|
3
4
|
import { login } from './login.js';
|
|
4
5
|
import { serve } from './serve.js';
|
|
5
6
|
import { push } from './push.js';
|
|
6
7
|
import { init } from './init.js';
|
|
8
|
+
import { savePreviewImage, savePreviewIfNeeded } from './preview.js';
|
|
7
9
|
|
|
8
10
|
const args = process.argv.slice(2);
|
|
9
11
|
const command = args[0];
|
|
@@ -12,10 +14,11 @@ function usage() {
|
|
|
12
14
|
console.log(`Usage:
|
|
13
15
|
castle-web init <dir>
|
|
14
16
|
castle-web serve [dir] [--port PORT] [--open]
|
|
17
|
+
castle-web restart [--port PORT]
|
|
18
|
+
castle-web screenshot [--out FILE] [--port PORT]
|
|
19
|
+
castle-web save-preview-image [dir] [--port PORT]
|
|
15
20
|
castle-web push [dir]
|
|
16
|
-
castle-web login
|
|
17
|
-
|
|
18
|
-
Install: cd castle-experimental-web/cli && npm install && npm run build && npm link`);
|
|
21
|
+
castle-web login`);
|
|
19
22
|
process.exit(1);
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -37,9 +40,62 @@ async function main() {
|
|
|
37
40
|
}
|
|
38
41
|
case 'push': {
|
|
39
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);
|
|
40
45
|
await push(dir);
|
|
46
|
+
await savePreviewIfNeeded(dir, wsPort);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case 'restart': {
|
|
50
|
+
const portIdx = args.indexOf('--port');
|
|
51
|
+
const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
|
|
52
|
+
const { default: WS } = await import('ws');
|
|
53
|
+
const ws = new WS(`ws://localhost:${wsPort}`);
|
|
54
|
+
ws.on('open', () => {
|
|
55
|
+
ws.send(JSON.stringify({ type: 'restart' }));
|
|
56
|
+
setTimeout(() => { ws.close(); process.exit(0); }, 100);
|
|
57
|
+
});
|
|
58
|
+
ws.on('error', () => { console.error('Could not connect. Is castle-web serve running?'); process.exit(1); });
|
|
41
59
|
break;
|
|
42
60
|
}
|
|
61
|
+
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);
|
|
66
|
+
const requestId = Math.random().toString(36).slice(2);
|
|
67
|
+
const { default: WS } = await import('ws');
|
|
68
|
+
const ws = new WS(`ws://localhost:${wsPort}`);
|
|
69
|
+
ws.on('open', () => {
|
|
70
|
+
ws.send(JSON.stringify({ type: 'screenshot_request', requestId }));
|
|
71
|
+
});
|
|
72
|
+
const timeout = setTimeout(() => {
|
|
73
|
+
console.error('Screenshot timed out. Is castle-web serve running?');
|
|
74
|
+
ws.close();
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}, 3000);
|
|
77
|
+
ws.on('message', (raw: Buffer) => {
|
|
78
|
+
try {
|
|
79
|
+
const msg = JSON.parse(raw.toString());
|
|
80
|
+
if (msg.type === 'screenshot_response' && msg.requestId === requestId) {
|
|
81
|
+
clearTimeout(timeout);
|
|
82
|
+
const base64 = msg.data.replace(/^data:image\/png;base64,/, '');
|
|
83
|
+
fs.writeFileSync(outFile, Buffer.from(base64, 'base64'));
|
|
84
|
+
console.log(`Saved ${outFile}`);
|
|
85
|
+
ws.close();
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
} catch {}
|
|
89
|
+
});
|
|
90
|
+
break;
|
|
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
|
+
}
|
|
43
99
|
case 'login':
|
|
44
100
|
await login();
|
|
45
101
|
break;
|
package/src/init.ts
CHANGED
|
@@ -13,19 +13,23 @@ const INDEX_HTML = `<!DOCTYPE html>
|
|
|
13
13
|
</html>
|
|
14
14
|
`;
|
|
15
15
|
|
|
16
|
-
const GAME_JS = `import
|
|
16
|
+
const GAME_JS = `import { setup, initCard } from 'castle-web-sdk';
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
const card =
|
|
18
|
+
setup();
|
|
19
|
+
const card = initCard();
|
|
20
20
|
|
|
21
|
-
const
|
|
22
|
-
|
|
21
|
+
const el = document.createElement('div');
|
|
22
|
+
el.style.cssText = \`
|
|
23
23
|
width: 100%; height: 100%;
|
|
24
24
|
display: flex; align-items: center; justify-content: center;
|
|
25
25
|
font-family: system-ui; color: #fff; font-size: 24px;
|
|
26
26
|
\`;
|
|
27
|
-
|
|
28
|
-
card.appendChild(
|
|
27
|
+
el.textContent = 'Hello Castle!';
|
|
28
|
+
card.appendChild(el);
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const CLAUDE_MD = `@node_modules/castle-web-cli/AGENTS.md
|
|
32
|
+
@node_modules/castle-web-sdk/AGENTS.md
|
|
29
33
|
`;
|
|
30
34
|
|
|
31
35
|
export async function init(dir: string) {
|
|
@@ -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/push.ts
CHANGED
|
@@ -49,8 +49,12 @@ export async function push(dir: string) {
|
|
|
49
49
|
let castleJson = readCastleJson(projectDir);
|
|
50
50
|
|
|
51
51
|
console.log('Bundling...');
|
|
52
|
-
const bundle = bundleProject(projectDir);
|
|
53
|
-
|
|
52
|
+
const bundle = await bundleProject(projectDir);
|
|
53
|
+
const sizeKB = bundle.length / 1024;
|
|
54
|
+
console.log(`Bundle size: ${sizeKB.toFixed(1)}KB`);
|
|
55
|
+
if (sizeKB > 1024) {
|
|
56
|
+
console.warn(`Warning: Bundle is ${(sizeKB / 1024).toFixed(1)}MB. Large bundles may load slowly.`);
|
|
57
|
+
}
|
|
54
58
|
|
|
55
59
|
const sceneData = buildSceneData(bundle);
|
|
56
60
|
const isNew = !castleJson;
|
|
@@ -85,7 +89,7 @@ export async function push(dir: string) {
|
|
|
85
89
|
const title = path.basename(path.resolve(projectDir));
|
|
86
90
|
try {
|
|
87
91
|
const result = await api.updateCardAndDeckV2(
|
|
88
|
-
{ deckId, title },
|
|
92
|
+
{ deckId, title, visibility: 'unlisted' },
|
|
89
93
|
{ cardId, blocks: [], uploadId: uploadConfig.uploadId, makeInitialCard: true }
|
|
90
94
|
);
|
|
91
95
|
|
package/src/serve.ts
CHANGED
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import
|
|
3
|
+
import { createServer, type Plugin } from 'vite';
|
|
4
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'.mp4': 'video/mp4',
|
|
21
|
-
'.webm': 'video/webm',
|
|
22
|
-
'.ttf': 'font/ttf',
|
|
23
|
-
'.woff': 'font/woff',
|
|
24
|
-
'.woff2': 'font/woff2',
|
|
25
|
-
};
|
|
6
|
+
function castlePlugin(wsPort: number): Plugin {
|
|
7
|
+
return {
|
|
8
|
+
name: 'castle-dev',
|
|
9
|
+
configureServer(server) {
|
|
10
|
+
server.middlewares.use((req, res, next) => {
|
|
11
|
+
if (req.url === '/__castle/ws-port') {
|
|
12
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
13
|
+
res.end(JSON.stringify({ port: wsPort }));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
next();
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
26
21
|
|
|
27
22
|
export async function serve(
|
|
28
23
|
dir: string,
|
|
@@ -33,103 +28,81 @@ export async function serve(
|
|
|
33
28
|
console.error(`Directory not found: ${projectDir}`);
|
|
34
29
|
process.exit(1);
|
|
35
30
|
}
|
|
36
|
-
|
|
37
|
-
const indexPath = path.join(projectDir, 'index.html');
|
|
38
|
-
if (!fs.existsSync(indexPath)) {
|
|
31
|
+
if (!fs.existsSync(path.join(projectDir, 'index.html'))) {
|
|
39
32
|
console.error(`No index.html found in ${projectDir}`);
|
|
40
33
|
process.exit(1);
|
|
41
34
|
}
|
|
42
35
|
|
|
43
36
|
const port = parseInt(options.port ?? '3737', 10);
|
|
44
|
-
|
|
45
|
-
const pendingVersionPolls: Array<{ clientVersion: number; res: http.ServerResponse }> = [];
|
|
37
|
+
const wsPort = port + 1;
|
|
46
38
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
}
|
|
39
|
+
// Local WebSocket server for SDK communication
|
|
40
|
+
const castleDir = path.join(projectDir, '.castle');
|
|
41
|
+
if (!fs.existsSync(castleDir)) fs.mkdirSync(castleDir, { recursive: true });
|
|
42
|
+
const logFile = path.join(castleDir, 'logs.txt');
|
|
43
|
+
const screenshotsDir = path.join(castleDir, 'screenshots');
|
|
54
44
|
|
|
55
|
-
|
|
56
|
-
fs.watch(projectDir, { recursive: true }, (event, filename) => {
|
|
57
|
-
if (filename && !filename.startsWith('.')) {
|
|
58
|
-
version++;
|
|
59
|
-
flushVersionPolls();
|
|
60
|
-
}
|
|
61
|
-
});
|
|
45
|
+
startWSServer(wsPort, logFile, screenshotsDir);
|
|
62
46
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
47
|
+
const vite = await createServer({
|
|
48
|
+
root: projectDir,
|
|
49
|
+
plugins: [castlePlugin(wsPort)],
|
|
50
|
+
server: {
|
|
51
|
+
port,
|
|
52
|
+
strictPort: true,
|
|
53
|
+
open: options.open ? true : undefined,
|
|
54
|
+
hmr: false,
|
|
55
|
+
fs: { strict: false },
|
|
56
|
+
},
|
|
57
|
+
logLevel: 'info',
|
|
58
|
+
});
|
|
66
59
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (clientVersion < version) {
|
|
71
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
72
|
-
res.end(JSON.stringify({ version }));
|
|
73
|
-
} else {
|
|
74
|
-
// Long poll — wait for next change
|
|
75
|
-
pendingVersionPolls.push({ clientVersion, res });
|
|
76
|
-
const timeout = setTimeout(() => {
|
|
77
|
-
const idx = pendingVersionPolls.findIndex((p) => p.res === res);
|
|
78
|
-
if (idx >= 0) pendingVersionPolls.splice(idx, 1);
|
|
79
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
80
|
-
res.end(JSON.stringify({ version }));
|
|
81
|
-
}, 30000);
|
|
82
|
-
res.on('close', () => {
|
|
83
|
-
clearTimeout(timeout);
|
|
84
|
-
const idx = pendingVersionPolls.findIndex((p) => p.res === res);
|
|
85
|
-
if (idx >= 0) pendingVersionPolls.splice(idx, 1);
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
60
|
+
await vite.listen();
|
|
61
|
+
vite.printUrls();
|
|
62
|
+
}
|
|
90
63
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
64
|
+
function startWSServer(port: number, logFile: string, screenshotsDir: string): WebSocketServer {
|
|
65
|
+
const wss = new WebSocketServer({ port });
|
|
66
|
+
const clients = new Set<WebSocket>();
|
|
94
67
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
res.writeHead(403);
|
|
98
|
-
res.end('Forbidden');
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
68
|
+
wss.on('connection', (ws) => {
|
|
69
|
+
clients.add(ws);
|
|
101
70
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
71
|
+
ws.on('message', (raw) => {
|
|
72
|
+
try {
|
|
73
|
+
const msg = JSON.parse(raw.toString());
|
|
107
74
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
75
|
+
if (msg.type === 'restart' || msg.type === 'screenshot_request') {
|
|
76
|
+
const fwd = JSON.stringify(msg);
|
|
77
|
+
for (const c of clients) {
|
|
78
|
+
if (c !== ws && c.readyState === WebSocket.OPEN) c.send(fwd);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
117
81
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
82
|
+
if (msg.type === 'log') {
|
|
83
|
+
const line = `[${msg.level}] ${msg.msg}\n`;
|
|
84
|
+
fs.appendFileSync(logFile, line);
|
|
85
|
+
if (msg.level === 'error') process.stderr.write(line);
|
|
86
|
+
else process.stdout.write(line);
|
|
87
|
+
}
|
|
124
88
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
89
|
+
if (msg.type === 'screenshot_response') {
|
|
90
|
+
if (!fs.existsSync(screenshotsDir)) fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
91
|
+
const filename = `screenshot-${Date.now()}.png`;
|
|
92
|
+
const filepath = path.join(screenshotsDir, filename);
|
|
93
|
+
const base64 = msg.data.replace(/^data:image\/png;base64,/, '');
|
|
94
|
+
fs.writeFileSync(filepath, Buffer.from(base64, 'base64'));
|
|
95
|
+
console.log(`Screenshot saved: ${filepath}`);
|
|
96
|
+
const fwd = JSON.stringify(msg);
|
|
97
|
+
for (const c of clients) {
|
|
98
|
+
if (c !== ws && c.readyState === WebSocket.OPEN) c.send(fwd);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {}
|
|
102
|
+
});
|
|
130
103
|
|
|
131
|
-
|
|
132
|
-
import('open').then(({ default: open }) => open(url));
|
|
133
|
-
}
|
|
104
|
+
ws.on('close', () => { clients.delete(ws); });
|
|
134
105
|
});
|
|
106
|
+
|
|
107
|
+
return wss;
|
|
135
108
|
}
|