castle-web-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,22 @@
1
+ # Castle Web CLI
2
+
3
+ ## Commands
4
+
5
+ - `castle-web init <dir>` — scaffold a new project (index.html, game.js, castle.js SDK)
6
+ - `castle-web serve [dir] [--port PORT] [--open]` — local dev server with hot reload (default port 3737)
7
+ - `castle-web push [dir]` — bundle with esbuild and publish to Castle. Creates a new deck if no castle.json exists.
8
+ - `castle-web login` — authenticate with Castle (saves token to ~/.castle/config.json)
9
+
10
+ ## Project Structure
11
+
12
+ ```
13
+ my-game/
14
+ index.html # entry point
15
+ game.js # game code
16
+ castle.js # SDK (copied by init)
17
+ castle.json # deck/card IDs (created by first push)
18
+ ```
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 ADDED
@@ -0,0 +1,20 @@
1
+ export declare function startCLILogin(): Promise<{
2
+ pollToken: string;
3
+ url: string;
4
+ }>;
5
+ export declare function pollForCLILogin(pollToken: string): Promise<any>;
6
+ export declare function me(): Promise<any>;
7
+ export declare function myDecks(): Promise<any[]>;
8
+ export declare function updateCardAndDeckV2(deck: Record<string, unknown>, card: Record<string, unknown>): Promise<{
9
+ deckId: string;
10
+ cardId: string;
11
+ }>;
12
+ export declare function createDeck(deck: Record<string, unknown>, card: Record<string, unknown>): Promise<{
13
+ deckId: string;
14
+ cardId: string;
15
+ }>;
16
+ export declare function createSceneDataUploadConfig(cardIds: string[]): Promise<any[]>;
17
+ export declare function uploadSceneData(cards: Array<{
18
+ cardId: string;
19
+ uploadId: string;
20
+ }>): Promise<any[]>;
package/dist/api.js ADDED
@@ -0,0 +1,105 @@
1
+ import * as config from './config.js';
2
+ const API_HOST = 'https://api.castle.xyz/graphql';
3
+ async function graphql(query, variables) {
4
+ const token = config.getToken();
5
+ const headers = {
6
+ 'X-OS': 'cli',
7
+ 'X-CLI-API-Version': '2',
8
+ 'Content-Type': 'application/json',
9
+ Accept: 'application/json',
10
+ };
11
+ if (token)
12
+ headers['X-Auth-Token'] = token;
13
+ const res = await fetch(API_HOST, {
14
+ method: 'POST',
15
+ headers,
16
+ body: JSON.stringify(variables ? { query, variables } : { query }),
17
+ signal: AbortSignal.timeout(10000),
18
+ });
19
+ return res.json();
20
+ }
21
+ function handleAPIError(data) {
22
+ if (data?.errors?.length) {
23
+ const err = new Error(data.errors[0]?.message ?? 'GraphQL error');
24
+ err.extensions = data.errors[0]?.extensions;
25
+ throw err;
26
+ }
27
+ }
28
+ export async function startCLILogin() {
29
+ const data = await graphql(`mutation { startCLILogin { pollToken url } }`);
30
+ handleAPIError(data);
31
+ return data.data.startCLILogin;
32
+ }
33
+ export async function pollForCLILogin(pollToken) {
34
+ const data = await graphql(`query($pollToken: String!) {
35
+ pollForCLILogin(pollToken: $pollToken) {
36
+ userId username token isAnonymous
37
+ }
38
+ }`, { pollToken });
39
+ handleAPIError(data);
40
+ return data.data?.pollForCLILogin ?? null;
41
+ }
42
+ export async function me() {
43
+ try {
44
+ const data = await graphql(`query { me { userId username isAnonymous } }`);
45
+ return data.data?.me ?? null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ export async function myDecks() {
52
+ const data = await graphql(`query {
53
+ me {
54
+ decks {
55
+ deckId
56
+ title
57
+ initialCard { cardId }
58
+ }
59
+ }
60
+ }`);
61
+ handleAPIError(data);
62
+ return data.data?.me?.decks ?? [];
63
+ }
64
+ export async function updateCardAndDeckV2(deck, card) {
65
+ const data = await graphql(`mutation($deck: DeckInput!, $card: CardInput!) {
66
+ updateCardAndDeckV2(deck: $deck, card: $card) {
67
+ deck { deckId }
68
+ card { cardId }
69
+ }
70
+ }`, { deck, card });
71
+ handleAPIError(data);
72
+ return {
73
+ deckId: data.data.updateCardAndDeckV2.deck.deckId,
74
+ cardId: data.data.updateCardAndDeckV2.card.cardId,
75
+ };
76
+ }
77
+ export async function createDeck(deck, card) {
78
+ const data = await graphql(`mutation($deck: DeckInput!, $card: CardInput!) {
79
+ createDeck(deck: $deck, card: $card) {
80
+ deckId
81
+ initialCard { cardId }
82
+ }
83
+ }`, { deck, card });
84
+ handleAPIError(data);
85
+ return {
86
+ deckId: data.data.createDeck.deckId,
87
+ cardId: data.data.createDeck.initialCard.cardId,
88
+ };
89
+ }
90
+ export async function createSceneDataUploadConfig(cardIds) {
91
+ const data = await graphql(`mutation($cardIds: [ID!]!) {
92
+ createSceneDataUploadConfig(cardIds: $cardIds) {
93
+ cardId uploadId postUrl postFields
94
+ }
95
+ }`, { cardIds });
96
+ handleAPIError(data);
97
+ return data.data.createSceneDataUploadConfig;
98
+ }
99
+ export async function uploadSceneData(cards) {
100
+ const data = await graphql(`mutation($cards: [CardSceneDataInput!]!) {
101
+ uploadSceneData(cards: $cards) { cardId sceneDataUrl }
102
+ }`, { cards });
103
+ handleAPIError(data);
104
+ return data.data.uploadSceneData;
105
+ }
@@ -0,0 +1 @@
1
+ export declare function bundleProject(dir: string): string;
package/dist/bundle.js ADDED
@@ -0,0 +1,27 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as esbuild from 'esbuild';
4
+ export function bundleProject(dir) {
5
+ const indexPath = path.join(dir, 'index.html');
6
+ if (!fs.existsSync(indexPath)) {
7
+ throw new Error(`No index.html found in ${dir}`);
8
+ }
9
+ let html = fs.readFileSync(indexPath, 'utf-8');
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',
19
+ write: false,
20
+ minify: false,
21
+ });
22
+ const code = result.outputFiles[0].text;
23
+ const attrs = `${before}${after}`.trim();
24
+ return `<script type="module"${attrs ? ' ' + attrs : ''}>\n${code}</script>`;
25
+ });
26
+ return html;
27
+ }
@@ -0,0 +1,2 @@
1
+ export declare function getToken(): string | null;
2
+ export declare function setToken(token: string | null): void;
package/dist/config.js ADDED
@@ -0,0 +1,34 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ function getConfigDir() {
5
+ return path.join(os.homedir(), '.castle');
6
+ }
7
+ function readConfigFile(filename) {
8
+ const configDir = getConfigDir();
9
+ const configFilePath = path.join(configDir, filename);
10
+ try {
11
+ if (fs.existsSync(configFilePath)) {
12
+ return JSON.parse(fs.readFileSync(configFilePath, 'utf-8'));
13
+ }
14
+ }
15
+ catch { }
16
+ return null;
17
+ }
18
+ export function getToken() {
19
+ const config = readConfigFile('config.json');
20
+ return config ? config.token : null;
21
+ }
22
+ export function setToken(token) {
23
+ const configDir = getConfigDir();
24
+ fs.mkdirSync(configDir, { recursive: true });
25
+ const existing = readConfigFile('config.json') ?? {};
26
+ if (token === null) {
27
+ delete existing.token;
28
+ }
29
+ else {
30
+ existing.token = token;
31
+ }
32
+ const configFilePath = path.join(configDir, 'config.json');
33
+ fs.writeFileSync(configFilePath, JSON.stringify(existing, null, 2), 'utf-8');
34
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { login } from './login.js';
3
+ import { serve } from './serve.js';
4
+ import { push } from './push.js';
5
+ import { init } from './init.js';
6
+ const args = process.argv.slice(2);
7
+ const command = args[0];
8
+ function usage() {
9
+ console.log(`Usage:
10
+ castle-web init <dir>
11
+ castle-web serve [dir] [--port PORT] [--open]
12
+ castle-web push [dir]
13
+ castle-web login
14
+
15
+ Install: cd castle-experimental-web/cli && npm install && npm run build && npm link`);
16
+ process.exit(1);
17
+ }
18
+ async function main() {
19
+ switch (command) {
20
+ case 'init': {
21
+ const dir = args[1];
22
+ if (!dir) {
23
+ console.error('Usage: castle-web init <dir>');
24
+ process.exit(1);
25
+ }
26
+ init(dir);
27
+ break;
28
+ }
29
+ case 'serve': {
30
+ const dir = args.find((a, i) => i > 0 && !a.startsWith('--')) ?? '.';
31
+ const portIdx = args.indexOf('--port');
32
+ const port = portIdx >= 0 ? args[portIdx + 1] : undefined;
33
+ const open = args.includes('--open');
34
+ await serve(dir, { port, open });
35
+ break;
36
+ }
37
+ case 'push': {
38
+ const dir = args.find((a, i) => i > 0 && !a.startsWith('--')) ?? '.';
39
+ await push(dir);
40
+ break;
41
+ }
42
+ case 'login':
43
+ await login();
44
+ break;
45
+ default:
46
+ usage();
47
+ }
48
+ }
49
+ main().catch((e) => {
50
+ console.error(e.message ?? e);
51
+ process.exit(1);
52
+ });
package/dist/init.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function init(dir: string): Promise<void>;
package/dist/init.js ADDED
@@ -0,0 +1,57 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ const INDEX_HTML = `<!DOCTYPE html>
4
+ <html>
5
+ <head>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
7
+ <title>My Castle Game</title>
8
+ </head>
9
+ <body>
10
+ <script type="module" src="game.js"></script>
11
+ </body>
12
+ </html>
13
+ `;
14
+ const GAME_JS = `import * as castle from 'castle-web-sdk';
15
+
16
+ castle.enableHotReload();
17
+ const card = castle.initCard();
18
+
19
+ const div = document.createElement('div');
20
+ div.style.cssText = \`
21
+ width: 100%; height: 100%;
22
+ display: flex; align-items: center; justify-content: center;
23
+ font-family: system-ui; color: #fff; font-size: 24px;
24
+ \`;
25
+ div.textContent = 'Hello Castle!';
26
+ card.appendChild(div);
27
+ `;
28
+ export async function init(dir) {
29
+ const projectDir = path.resolve(dir);
30
+ if (fs.existsSync(projectDir) && fs.readdirSync(projectDir).length > 0) {
31
+ console.error(`Directory "${dir}" is not empty.`);
32
+ process.exit(1);
33
+ }
34
+ fs.mkdirSync(projectDir, { recursive: true });
35
+ fs.writeFileSync(path.join(projectDir, 'index.html'), INDEX_HTML);
36
+ fs.writeFileSync(path.join(projectDir, 'game.js'), GAME_JS);
37
+ // Create package.json with SDK dependency
38
+ const packageJson = {
39
+ name: path.basename(projectDir),
40
+ private: true,
41
+ type: 'module',
42
+ dependencies: {
43
+ 'castle-web-sdk': '*',
44
+ },
45
+ };
46
+ fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify(packageJson, null, 2) + '\n');
47
+ // Install dependencies (picks up npm-linked SDK)
48
+ const { execSync } = await import('child_process');
49
+ execSync('npm install', { cwd: projectDir, stdio: 'inherit' });
50
+ console.log(`Created project in ${projectDir}/`);
51
+ console.log('');
52
+ console.log('Next steps:');
53
+ console.log(` cd ${dir}`);
54
+ console.log(' castle-web serve --open');
55
+ console.log(' castle-web login');
56
+ console.log(' castle-web push');
57
+ }
@@ -0,0 +1 @@
1
+ export declare function login(): Promise<void>;
package/dist/login.js ADDED
@@ -0,0 +1,21 @@
1
+ import * as api from './api.js';
2
+ import * as config from './config.js';
3
+ export async function login() {
4
+ const { pollToken, url } = await api.startCLILogin();
5
+ console.log(`Opening browser to log in: ${url}`);
6
+ const { default: open } = await import('open');
7
+ await open(url);
8
+ console.log('Waiting for login...');
9
+ const MAX_POLLS = 600;
10
+ for (let i = 0; i < MAX_POLLS; i++) {
11
+ await new Promise((r) => setTimeout(r, 1000));
12
+ const user = await api.pollForCLILogin(pollToken);
13
+ if (user) {
14
+ config.setToken(user.token);
15
+ console.log(`Logged in as @${user.username}`);
16
+ return;
17
+ }
18
+ }
19
+ console.error('Login timed out.');
20
+ process.exit(1);
21
+ }
package/dist/push.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function push(dir: string): Promise<void>;
package/dist/push.js ADDED
@@ -0,0 +1,95 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { nanoid } from 'nanoid';
4
+ import * as api from './api.js';
5
+ import * as config from './config.js';
6
+ import { bundleProject } from './bundle.js';
7
+ function readCastleJson(dir) {
8
+ const p = path.join(dir, 'castle.json');
9
+ if (!fs.existsSync(p))
10
+ return null;
11
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
12
+ }
13
+ function buildSceneData(bundle) {
14
+ return {
15
+ experimentalWeb: {
16
+ enabled: true,
17
+ bundle,
18
+ },
19
+ snapshot: {
20
+ sceneProperties: {
21
+ backgroundColor: { r: 0, g: 0, b: 0, a: 1 },
22
+ coordinateSystemVersion: 2,
23
+ },
24
+ actors: [],
25
+ library: {},
26
+ linkTargetDeckIds: [],
27
+ },
28
+ };
29
+ }
30
+ export async function push(dir) {
31
+ if (!config.getToken()) {
32
+ console.error('Not logged in. Run `castle-web login` first.');
33
+ process.exit(1);
34
+ }
35
+ const projectDir = path.resolve(dir);
36
+ if (!fs.existsSync(path.join(projectDir, 'index.html'))) {
37
+ console.error(`No index.html found in ${projectDir}`);
38
+ process.exit(1);
39
+ }
40
+ let castleJson = readCastleJson(projectDir);
41
+ console.log('Bundling...');
42
+ const bundle = bundleProject(projectDir);
43
+ console.log(`Bundle size: ${(bundle.length / 1024).toFixed(1)}KB`);
44
+ const sceneData = buildSceneData(bundle);
45
+ const isNew = !castleJson;
46
+ const cardId = castleJson?.cardId ?? nanoid(12);
47
+ const deckId = castleJson?.deckId ?? nanoid(12);
48
+ // Upload scene data to S3
49
+ const configs = await api.createSceneDataUploadConfig([cardId]);
50
+ if (!configs?.length) {
51
+ console.error('Failed to get upload config.');
52
+ process.exit(1);
53
+ }
54
+ const uploadConfig = configs[0];
55
+ const formData = new FormData();
56
+ formData.append('Content-Type', 'application/json');
57
+ for (const [k, v] of Object.entries(uploadConfig.postFields)) {
58
+ formData.append(k, `${v}`);
59
+ }
60
+ formData.append('file', new Blob([JSON.stringify(sceneData)]));
61
+ const s3Res = await fetch(uploadConfig.postUrl, {
62
+ method: 'POST',
63
+ body: formData,
64
+ signal: AbortSignal.timeout(30000),
65
+ });
66
+ if (s3Res.status >= 300) {
67
+ throw new Error(`Upload failed: HTTP ${s3Res.status}`);
68
+ }
69
+ // Create or update via updateCardAndDeckV2
70
+ const title = path.basename(path.resolve(projectDir));
71
+ try {
72
+ const result = await api.updateCardAndDeckV2({ deckId, title }, { cardId, blocks: [], uploadId: uploadConfig.uploadId, makeInitialCard: true });
73
+ if (isNew) {
74
+ const newCastleJson = { deckId: result.deckId, cardId: result.cardId };
75
+ fs.writeFileSync(path.join(projectDir, 'castle.json'), JSON.stringify(newCastleJson, null, 2) + '\n', 'utf-8');
76
+ console.log(`Created deck "${title}" (${result.deckId}). Saved castle.json.`);
77
+ }
78
+ else {
79
+ console.log('Pushed to Castle.');
80
+ }
81
+ }
82
+ catch (e) {
83
+ const code = e?.extensions?.code ?? '';
84
+ if (code === 'LOGIN_REQUIRED') {
85
+ console.error('Not logged in. Run `castle-web login` first.');
86
+ }
87
+ else if (code === 'DECK_INVALID_PERMISSIONS') {
88
+ console.error('You do not have permission to push to this deck.');
89
+ }
90
+ else {
91
+ throw e;
92
+ }
93
+ process.exit(1);
94
+ }
95
+ }
@@ -0,0 +1,4 @@
1
+ export declare function serve(dir: string, options?: {
2
+ port?: string;
3
+ open?: boolean;
4
+ }): Promise<void>;
package/dist/serve.js ADDED
@@ -0,0 +1,120 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as http from 'http';
4
+ const MIME_TYPES = {
5
+ '.html': 'text/html',
6
+ '.js': 'application/javascript',
7
+ '.mjs': 'application/javascript',
8
+ '.css': 'text/css',
9
+ '.json': 'application/json',
10
+ '.png': 'image/png',
11
+ '.jpg': 'image/jpeg',
12
+ '.gif': 'image/gif',
13
+ '.svg': 'image/svg+xml',
14
+ '.wasm': 'application/wasm',
15
+ '.ico': 'image/x-icon',
16
+ '.mp3': 'audio/mpeg',
17
+ '.ogg': 'audio/ogg',
18
+ '.wav': 'audio/wav',
19
+ '.mp4': 'video/mp4',
20
+ '.webm': 'video/webm',
21
+ '.ttf': 'font/ttf',
22
+ '.woff': 'font/woff',
23
+ '.woff2': 'font/woff2',
24
+ };
25
+ export async function serve(dir, options = {}) {
26
+ const projectDir = path.resolve(dir);
27
+ if (!fs.existsSync(projectDir)) {
28
+ console.error(`Directory not found: ${projectDir}`);
29
+ process.exit(1);
30
+ }
31
+ const indexPath = path.join(projectDir, 'index.html');
32
+ if (!fs.existsSync(indexPath)) {
33
+ console.error(`No index.html found in ${projectDir}`);
34
+ process.exit(1);
35
+ }
36
+ const port = parseInt(options.port ?? '3737', 10);
37
+ let version = 0;
38
+ const pendingVersionPolls = [];
39
+ function flushVersionPolls() {
40
+ while (pendingVersionPolls.length > 0) {
41
+ const poll = pendingVersionPolls.shift();
42
+ poll.res.writeHead(200, { 'Content-Type': 'application/json' });
43
+ poll.res.end(JSON.stringify({ version }));
44
+ }
45
+ }
46
+ // Watch project dir for changes
47
+ fs.watch(projectDir, { recursive: true }, (event, filename) => {
48
+ if (filename && !filename.startsWith('.')) {
49
+ version++;
50
+ flushVersionPolls();
51
+ }
52
+ });
53
+ const server = http.createServer((req, res) => {
54
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
55
+ const pathname = url.pathname;
56
+ // Version polling for hot reload
57
+ if (pathname === '/__version') {
58
+ const clientVersion = parseInt(url.searchParams.get('v') ?? '0', 10);
59
+ if (clientVersion < version) {
60
+ res.writeHead(200, { 'Content-Type': 'application/json' });
61
+ res.end(JSON.stringify({ version }));
62
+ }
63
+ else {
64
+ // Long poll — wait for next change
65
+ pendingVersionPolls.push({ clientVersion, res });
66
+ const timeout = setTimeout(() => {
67
+ const idx = pendingVersionPolls.findIndex((p) => p.res === res);
68
+ if (idx >= 0)
69
+ pendingVersionPolls.splice(idx, 1);
70
+ res.writeHead(200, { 'Content-Type': 'application/json' });
71
+ res.end(JSON.stringify({ version }));
72
+ }, 30000);
73
+ res.on('close', () => {
74
+ clearTimeout(timeout);
75
+ const idx = pendingVersionPolls.findIndex((p) => p.res === res);
76
+ if (idx >= 0)
77
+ pendingVersionPolls.splice(idx, 1);
78
+ });
79
+ }
80
+ return;
81
+ }
82
+ // Serve static files
83
+ let filePath = path.join(projectDir, pathname === '/' ? 'index.html' : pathname);
84
+ filePath = path.normalize(filePath);
85
+ // Security: don't serve files outside project dir
86
+ if (!filePath.startsWith(projectDir)) {
87
+ res.writeHead(403);
88
+ res.end('Forbidden');
89
+ return;
90
+ }
91
+ if (!fs.existsSync(filePath)) {
92
+ res.writeHead(404);
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;
103
+ }
104
+ }
105
+ const ext = path.extname(filePath).toLowerCase();
106
+ const contentType = MIME_TYPES[ext] ?? 'application/octet-stream';
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
+ }
119
+ });
120
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "castle-web-cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "castle-web": "./dist/index.js"
7
+ },
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "dev": "tsc --watch"
11
+ },
12
+ "dependencies": {
13
+ "esbuild": "^0.28.0",
14
+ "nanoid": "^5.1.7",
15
+ "open": "^10.0.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^20.0.0",
19
+ "typescript": "^5.0.0"
20
+ }
21
+ }
package/src/api.ts ADDED
@@ -0,0 +1,137 @@
1
+ import * as config from './config.js';
2
+
3
+ const API_HOST = 'https://api.castle.xyz/graphql';
4
+
5
+ async function graphql(query: string, variables?: Record<string, unknown>): Promise<any> {
6
+ const token = config.getToken();
7
+ const headers: Record<string, string> = {
8
+ 'X-OS': 'cli',
9
+ 'X-CLI-API-Version': '2',
10
+ 'Content-Type': 'application/json',
11
+ Accept: 'application/json',
12
+ };
13
+ if (token) headers['X-Auth-Token'] = token;
14
+ const res = await fetch(API_HOST, {
15
+ method: 'POST',
16
+ headers,
17
+ body: JSON.stringify(variables ? { query, variables } : { query }),
18
+ signal: AbortSignal.timeout(10000),
19
+ });
20
+ return res.json();
21
+ }
22
+
23
+ function handleAPIError(data: any): void {
24
+ if (data?.errors?.length) {
25
+ const err: any = new Error(data.errors[0]?.message ?? 'GraphQL error');
26
+ err.extensions = data.errors[0]?.extensions;
27
+ throw err;
28
+ }
29
+ }
30
+
31
+ export async function startCLILogin(): Promise<{ pollToken: string; url: string }> {
32
+ const data = await graphql(`mutation { startCLILogin { pollToken url } }`);
33
+ handleAPIError(data);
34
+ return data.data.startCLILogin;
35
+ }
36
+
37
+ export async function pollForCLILogin(pollToken: string): Promise<any> {
38
+ const data = await graphql(
39
+ `query($pollToken: String!) {
40
+ pollForCLILogin(pollToken: $pollToken) {
41
+ userId username token isAnonymous
42
+ }
43
+ }`,
44
+ { pollToken }
45
+ );
46
+ handleAPIError(data);
47
+ return data.data?.pollForCLILogin ?? null;
48
+ }
49
+
50
+ export async function me(): Promise<any> {
51
+ try {
52
+ const data = await graphql(`query { me { userId username isAnonymous } }`);
53
+ return data.data?.me ?? null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ export async function myDecks(): Promise<any[]> {
60
+ const data = await graphql(`query {
61
+ me {
62
+ decks {
63
+ deckId
64
+ title
65
+ initialCard { cardId }
66
+ }
67
+ }
68
+ }`);
69
+ handleAPIError(data);
70
+ return data.data?.me?.decks ?? [];
71
+ }
72
+
73
+ export async function updateCardAndDeckV2(
74
+ deck: Record<string, unknown>,
75
+ card: Record<string, unknown>,
76
+ ): Promise<{ deckId: string; cardId: string }> {
77
+ const data = await graphql(
78
+ `mutation($deck: DeckInput!, $card: CardInput!) {
79
+ updateCardAndDeckV2(deck: $deck, card: $card) {
80
+ deck { deckId }
81
+ card { cardId }
82
+ }
83
+ }`,
84
+ { deck, card }
85
+ );
86
+ handleAPIError(data);
87
+ return {
88
+ deckId: data.data.updateCardAndDeckV2.deck.deckId,
89
+ cardId: data.data.updateCardAndDeckV2.card.cardId,
90
+ };
91
+ }
92
+
93
+ export async function createDeck(
94
+ deck: Record<string, unknown>,
95
+ card: Record<string, unknown>
96
+ ): Promise<{ deckId: string; cardId: string }> {
97
+ const data = await graphql(
98
+ `mutation($deck: DeckInput!, $card: CardInput!) {
99
+ createDeck(deck: $deck, card: $card) {
100
+ deckId
101
+ initialCard { cardId }
102
+ }
103
+ }`,
104
+ { deck, card }
105
+ );
106
+ handleAPIError(data);
107
+ return {
108
+ deckId: data.data.createDeck.deckId,
109
+ cardId: data.data.createDeck.initialCard.cardId,
110
+ };
111
+ }
112
+
113
+ export async function createSceneDataUploadConfig(cardIds: string[]): Promise<any[]> {
114
+ const data = await graphql(
115
+ `mutation($cardIds: [ID!]!) {
116
+ createSceneDataUploadConfig(cardIds: $cardIds) {
117
+ cardId uploadId postUrl postFields
118
+ }
119
+ }`,
120
+ { cardIds }
121
+ );
122
+ handleAPIError(data);
123
+ return data.data.createSceneDataUploadConfig;
124
+ }
125
+
126
+ export async function uploadSceneData(
127
+ cards: Array<{ cardId: string; uploadId: string }>
128
+ ): Promise<any[]> {
129
+ const data = await graphql(
130
+ `mutation($cards: [CardSceneDataInput!]!) {
131
+ uploadSceneData(cards: $cards) { cardId sceneDataUrl }
132
+ }`,
133
+ { cards }
134
+ );
135
+ handleAPIError(data);
136
+ return data.data.uploadSceneData;
137
+ }
package/src/bundle.ts ADDED
@@ -0,0 +1,35 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as esbuild from 'esbuild';
4
+
5
+ export function bundleProject(dir: string): string {
6
+ const indexPath = path.join(dir, 'index.html');
7
+ if (!fs.existsSync(indexPath)) {
8
+ throw new Error(`No index.html found in ${dir}`);
9
+ }
10
+
11
+ let html = fs.readFileSync(indexPath, 'utf-8');
12
+
13
+ // Find <script src="..."> tags, bundle each with esbuild, inline the result
14
+ html = html.replace(
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;
19
+
20
+ const result = esbuild.buildSync({
21
+ entryPoints: [srcPath],
22
+ bundle: true,
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>`;
31
+ }
32
+ );
33
+
34
+ return html;
35
+ }
package/src/config.ts ADDED
@@ -0,0 +1,36 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+
5
+ function getConfigDir() {
6
+ return path.join(os.homedir(), '.castle');
7
+ }
8
+
9
+ function readConfigFile(filename: string): any {
10
+ const configDir = getConfigDir();
11
+ const configFilePath = path.join(configDir, filename);
12
+ try {
13
+ if (fs.existsSync(configFilePath)) {
14
+ return JSON.parse(fs.readFileSync(configFilePath, 'utf-8'));
15
+ }
16
+ } catch {}
17
+ return null;
18
+ }
19
+
20
+ export function getToken(): string | null {
21
+ const config = readConfigFile('config.json');
22
+ return config ? config.token : null;
23
+ }
24
+
25
+ export function setToken(token: string | null): void {
26
+ const configDir = getConfigDir();
27
+ fs.mkdirSync(configDir, { recursive: true });
28
+ const existing = readConfigFile('config.json') ?? {};
29
+ if (token === null) {
30
+ delete existing.token;
31
+ } else {
32
+ existing.token = token;
33
+ }
34
+ const configFilePath = path.join(configDir, 'config.json');
35
+ fs.writeFileSync(configFilePath, JSON.stringify(existing, null, 2), 'utf-8');
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { login } from './login.js';
4
+ import { serve } from './serve.js';
5
+ import { push } from './push.js';
6
+ import { init } from './init.js';
7
+
8
+ const args = process.argv.slice(2);
9
+ const command = args[0];
10
+
11
+ function usage() {
12
+ console.log(`Usage:
13
+ castle-web init <dir>
14
+ castle-web serve [dir] [--port PORT] [--open]
15
+ castle-web push [dir]
16
+ castle-web login
17
+
18
+ Install: cd castle-experimental-web/cli && npm install && npm run build && npm link`);
19
+ process.exit(1);
20
+ }
21
+
22
+ async function main() {
23
+ switch (command) {
24
+ case 'init': {
25
+ const dir = args[1];
26
+ if (!dir) { console.error('Usage: castle-web init <dir>'); process.exit(1); }
27
+ init(dir);
28
+ break;
29
+ }
30
+ case 'serve': {
31
+ const dir = args.find((a, i) => i > 0 && !a.startsWith('--')) ?? '.';
32
+ const portIdx = args.indexOf('--port');
33
+ const port = portIdx >= 0 ? args[portIdx + 1] : undefined;
34
+ const open = args.includes('--open');
35
+ await serve(dir, { port, open });
36
+ break;
37
+ }
38
+ case 'push': {
39
+ const dir = args.find((a, i) => i > 0 && !a.startsWith('--')) ?? '.';
40
+ await push(dir);
41
+ break;
42
+ }
43
+ case 'login':
44
+ await login();
45
+ break;
46
+ default:
47
+ usage();
48
+ }
49
+ }
50
+
51
+ main().catch((e) => {
52
+ console.error(e.message ?? e);
53
+ process.exit(1);
54
+ });
package/src/init.ts ADDED
@@ -0,0 +1,65 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ const INDEX_HTML = `<!DOCTYPE html>
5
+ <html>
6
+ <head>
7
+ <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
8
+ <title>My Castle Game</title>
9
+ </head>
10
+ <body>
11
+ <script type="module" src="game.js"></script>
12
+ </body>
13
+ </html>
14
+ `;
15
+
16
+ const GAME_JS = `import * as castle from 'castle-web-sdk';
17
+
18
+ castle.enableHotReload();
19
+ const card = castle.initCard();
20
+
21
+ const div = document.createElement('div');
22
+ div.style.cssText = \`
23
+ width: 100%; height: 100%;
24
+ display: flex; align-items: center; justify-content: center;
25
+ font-family: system-ui; color: #fff; font-size: 24px;
26
+ \`;
27
+ div.textContent = 'Hello Castle!';
28
+ card.appendChild(div);
29
+ `;
30
+
31
+ export async function init(dir: string) {
32
+ const projectDir = path.resolve(dir);
33
+
34
+ if (fs.existsSync(projectDir) && fs.readdirSync(projectDir).length > 0) {
35
+ console.error(`Directory "${dir}" is not empty.`);
36
+ process.exit(1);
37
+ }
38
+
39
+ fs.mkdirSync(projectDir, { recursive: true });
40
+ fs.writeFileSync(path.join(projectDir, 'index.html'), INDEX_HTML);
41
+ fs.writeFileSync(path.join(projectDir, 'game.js'), GAME_JS);
42
+
43
+ // Create package.json with SDK dependency
44
+ const packageJson = {
45
+ name: path.basename(projectDir),
46
+ private: true,
47
+ type: 'module',
48
+ dependencies: {
49
+ 'castle-web-sdk': '*',
50
+ },
51
+ };
52
+ fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify(packageJson, null, 2) + '\n');
53
+
54
+ // Install dependencies (picks up npm-linked SDK)
55
+ const { execSync } = await import('child_process');
56
+ execSync('npm install', { cwd: projectDir, stdio: 'inherit' });
57
+
58
+ console.log(`Created project in ${projectDir}/`);
59
+ console.log('');
60
+ console.log('Next steps:');
61
+ console.log(` cd ${dir}`);
62
+ console.log(' castle-web serve --open');
63
+ console.log(' castle-web login');
64
+ console.log(' castle-web push');
65
+ }
package/src/login.ts ADDED
@@ -0,0 +1,24 @@
1
+ import * as api from './api.js';
2
+ import * as config from './config.js';
3
+
4
+ export async function login(): Promise<void> {
5
+ const { pollToken, url } = await api.startCLILogin();
6
+ console.log(`Opening browser to log in: ${url}`);
7
+ const { default: open } = await import('open');
8
+ await open(url);
9
+ console.log('Waiting for login...');
10
+
11
+ const MAX_POLLS = 600;
12
+ for (let i = 0; i < MAX_POLLS; i++) {
13
+ await new Promise((r) => setTimeout(r, 1000));
14
+ const user = await api.pollForCLILogin(pollToken);
15
+ if (user) {
16
+ config.setToken(user.token);
17
+ console.log(`Logged in as @${user.username}`);
18
+ return;
19
+ }
20
+ }
21
+
22
+ console.error('Login timed out.');
23
+ process.exit(1);
24
+ }
package/src/push.ts ADDED
@@ -0,0 +1,114 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { nanoid } from 'nanoid';
4
+ import * as api from './api.js';
5
+ import * as config from './config.js';
6
+ import { bundleProject } from './bundle.js';
7
+
8
+ interface CastleJson {
9
+ deckId: string;
10
+ cardId: string;
11
+ }
12
+
13
+ function readCastleJson(dir: string): CastleJson | null {
14
+ const p = path.join(dir, 'castle.json');
15
+ if (!fs.existsSync(p)) return null;
16
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
17
+ }
18
+
19
+ function buildSceneData(bundle: string): any {
20
+ return {
21
+ experimentalWeb: {
22
+ enabled: true,
23
+ bundle,
24
+ },
25
+ snapshot: {
26
+ sceneProperties: {
27
+ backgroundColor: { r: 0, g: 0, b: 0, a: 1 },
28
+ coordinateSystemVersion: 2,
29
+ },
30
+ actors: [],
31
+ library: {},
32
+ linkTargetDeckIds: [],
33
+ },
34
+ };
35
+ }
36
+
37
+ export async function push(dir: string) {
38
+ if (!config.getToken()) {
39
+ console.error('Not logged in. Run `castle-web login` first.');
40
+ process.exit(1);
41
+ }
42
+
43
+ const projectDir = path.resolve(dir);
44
+ if (!fs.existsSync(path.join(projectDir, 'index.html'))) {
45
+ console.error(`No index.html found in ${projectDir}`);
46
+ process.exit(1);
47
+ }
48
+
49
+ let castleJson = readCastleJson(projectDir);
50
+
51
+ console.log('Bundling...');
52
+ const bundle = bundleProject(projectDir);
53
+ console.log(`Bundle size: ${(bundle.length / 1024).toFixed(1)}KB`);
54
+
55
+ const sceneData = buildSceneData(bundle);
56
+ const isNew = !castleJson;
57
+ const cardId = castleJson?.cardId ?? nanoid(12);
58
+ const deckId = castleJson?.deckId ?? nanoid(12);
59
+
60
+ // Upload scene data to S3
61
+ const configs = await api.createSceneDataUploadConfig([cardId]);
62
+ if (!configs?.length) {
63
+ console.error('Failed to get upload config.');
64
+ process.exit(1);
65
+ }
66
+
67
+ const uploadConfig = configs[0];
68
+ const formData = new FormData();
69
+ formData.append('Content-Type', 'application/json');
70
+ for (const [k, v] of Object.entries(uploadConfig.postFields)) {
71
+ formData.append(k, `${v}`);
72
+ }
73
+ formData.append('file', new Blob([JSON.stringify(sceneData)]));
74
+
75
+ const s3Res = await fetch(uploadConfig.postUrl, {
76
+ method: 'POST',
77
+ body: formData,
78
+ signal: AbortSignal.timeout(30000),
79
+ });
80
+ if (s3Res.status >= 300) {
81
+ throw new Error(`Upload failed: HTTP ${s3Res.status}`);
82
+ }
83
+
84
+ // Create or update via updateCardAndDeckV2
85
+ const title = path.basename(path.resolve(projectDir));
86
+ try {
87
+ const result = await api.updateCardAndDeckV2(
88
+ { deckId, title },
89
+ { cardId, blocks: [], uploadId: uploadConfig.uploadId, makeInitialCard: true }
90
+ );
91
+
92
+ if (isNew) {
93
+ const newCastleJson = { deckId: result.deckId, cardId: result.cardId };
94
+ fs.writeFileSync(
95
+ path.join(projectDir, 'castle.json'),
96
+ JSON.stringify(newCastleJson, null, 2) + '\n',
97
+ 'utf-8'
98
+ );
99
+ console.log(`Created deck "${title}" (${result.deckId}). Saved castle.json.`);
100
+ } else {
101
+ console.log('Pushed to Castle.');
102
+ }
103
+ } catch (e: any) {
104
+ const code = e?.extensions?.code ?? '';
105
+ if (code === 'LOGIN_REQUIRED') {
106
+ console.error('Not logged in. Run `castle-web login` first.');
107
+ } else if (code === 'DECK_INVALID_PERMISSIONS') {
108
+ console.error('You do not have permission to push to this deck.');
109
+ } else {
110
+ throw e;
111
+ }
112
+ process.exit(1);
113
+ }
114
+ }
package/src/serve.ts ADDED
@@ -0,0 +1,135 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as http from 'http';
4
+
5
+ const MIME_TYPES: Record<string, string> = {
6
+ '.html': 'text/html',
7
+ '.js': 'application/javascript',
8
+ '.mjs': 'application/javascript',
9
+ '.css': 'text/css',
10
+ '.json': 'application/json',
11
+ '.png': 'image/png',
12
+ '.jpg': 'image/jpeg',
13
+ '.gif': 'image/gif',
14
+ '.svg': 'image/svg+xml',
15
+ '.wasm': 'application/wasm',
16
+ '.ico': 'image/x-icon',
17
+ '.mp3': 'audio/mpeg',
18
+ '.ogg': 'audio/ogg',
19
+ '.wav': 'audio/wav',
20
+ '.mp4': 'video/mp4',
21
+ '.webm': 'video/webm',
22
+ '.ttf': 'font/ttf',
23
+ '.woff': 'font/woff',
24
+ '.woff2': 'font/woff2',
25
+ };
26
+
27
+ export async function serve(
28
+ dir: string,
29
+ options: { port?: string; open?: boolean } = {}
30
+ ) {
31
+ const projectDir = path.resolve(dir);
32
+ if (!fs.existsSync(projectDir)) {
33
+ console.error(`Directory not found: ${projectDir}`);
34
+ process.exit(1);
35
+ }
36
+
37
+ const indexPath = path.join(projectDir, 'index.html');
38
+ if (!fs.existsSync(indexPath)) {
39
+ console.error(`No index.html found in ${projectDir}`);
40
+ process.exit(1);
41
+ }
42
+
43
+ const port = parseInt(options.port ?? '3737', 10);
44
+ let version = 0;
45
+ const pendingVersionPolls: Array<{ clientVersion: number; res: http.ServerResponse }> = [];
46
+
47
+ function flushVersionPolls() {
48
+ while (pendingVersionPolls.length > 0) {
49
+ const poll = pendingVersionPolls.shift()!;
50
+ poll.res.writeHead(200, { 'Content-Type': 'application/json' });
51
+ poll.res.end(JSON.stringify({ version }));
52
+ }
53
+ }
54
+
55
+ // Watch project dir for changes
56
+ fs.watch(projectDir, { recursive: true }, (event, filename) => {
57
+ if (filename && !filename.startsWith('.')) {
58
+ version++;
59
+ flushVersionPolls();
60
+ }
61
+ });
62
+
63
+ const server = http.createServer((req, res) => {
64
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
65
+ const pathname = url.pathname;
66
+
67
+ // Version polling for hot reload
68
+ if (pathname === '/__version') {
69
+ const clientVersion = parseInt(url.searchParams.get('v') ?? '0', 10);
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
+ }
90
+
91
+ // Serve static files
92
+ let filePath = path.join(projectDir, pathname === '/' ? 'index.html' : pathname);
93
+ filePath = path.normalize(filePath);
94
+
95
+ // Security: don't serve files outside project dir
96
+ if (!filePath.startsWith(projectDir)) {
97
+ res.writeHead(403);
98
+ res.end('Forbidden');
99
+ return;
100
+ }
101
+
102
+ if (!fs.existsSync(filePath)) {
103
+ res.writeHead(404);
104
+ res.end('Not found');
105
+ return;
106
+ }
107
+
108
+ const stat = fs.statSync(filePath);
109
+ if (stat.isDirectory()) {
110
+ filePath = path.join(filePath, 'index.html');
111
+ if (!fs.existsSync(filePath)) {
112
+ res.writeHead(404);
113
+ res.end('Not found');
114
+ return;
115
+ }
116
+ }
117
+
118
+ const ext = path.extname(filePath).toLowerCase();
119
+ const contentType = MIME_TYPES[ext] ?? 'application/octet-stream';
120
+ const data = fs.readFileSync(filePath);
121
+ res.writeHead(200, { 'Content-Type': contentType });
122
+ res.end(data);
123
+ });
124
+
125
+ server.listen(port, () => {
126
+ const url = `http://localhost:${port}`;
127
+ console.log(`Serving ${projectDir}`);
128
+ console.log(` ${url}`);
129
+ console.log('Press Ctrl+C to stop.');
130
+
131
+ if (options.open) {
132
+ import('open').then(({ default: open }) => open(url));
133
+ }
134
+ });
135
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "declaration": true
11
+ },
12
+ "include": ["src"]
13
+ }