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 +22 -0
- package/dist/api.d.ts +20 -0
- package/dist/api.js +105 -0
- package/dist/bundle.d.ts +1 -0
- package/dist/bundle.js +27 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +34 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +52 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +57 -0
- package/dist/login.d.ts +1 -0
- package/dist/login.js +21 -0
- package/dist/push.d.ts +1 -0
- package/dist/push.js +95 -0
- package/dist/serve.d.ts +4 -0
- package/dist/serve.js +120 -0
- package/package.json +21 -0
- package/src/api.ts +137 -0
- package/src/bundle.ts +35 -0
- package/src/config.ts +36 -0
- package/src/index.ts +54 -0
- package/src/init.ts +65 -0
- package/src/login.ts +24 -0
- package/src/push.ts +114 -0
- package/src/serve.ts +135 -0
- package/tsconfig.json +13 -0
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
|
+
}
|
package/dist/bundle.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/config.d.ts
ADDED
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
|
+
}
|
package/dist/index.d.ts
ADDED
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
|
+
}
|
package/dist/login.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/serve.d.ts
ADDED
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