@uzuhq/code-cli 0.3.14

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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -0
  3. package/dist/auth/browser.js +22 -0
  4. package/dist/auth/config.js +48 -0
  5. package/dist/auth/env.js +42 -0
  6. package/dist/auth/jwt.js +27 -0
  7. package/dist/auth/login-flow.js +44 -0
  8. package/dist/auth/loopback.js +94 -0
  9. package/dist/auth/pkce.js +11 -0
  10. package/dist/auth/publish-token.js +74 -0
  11. package/dist/auth/token-cache.js +70 -0
  12. package/dist/auth/uzu-auth.js +94 -0
  13. package/dist/build-server-logic.js +28 -0
  14. package/dist/cf-images-upload.js +52 -0
  15. package/dist/cli.js +303 -0
  16. package/dist/create-2d-game.js +56 -0
  17. package/dist/dev-server/game-room.js +436 -0
  18. package/dist/dev-server/game-types.js +11 -0
  19. package/dist/dev-server/json-patch.js +114 -0
  20. package/dist/dev-server/load-logic.js +70 -0
  21. package/dist/dev-server/random.js +35 -0
  22. package/dist/dev-server/relay-room.js +84 -0
  23. package/dist/dev-server/server.js +367 -0
  24. package/dist/dev-server/sync-room.js +268 -0
  25. package/dist/dev.js +235 -0
  26. package/dist/harness/admin-client.js +215 -0
  27. package/dist/harness/client-entry.js +90 -0
  28. package/dist/harness/dev-button.js +249 -0
  29. package/dist/harness/mount.js +664 -0
  30. package/dist/harness/page.js +46 -0
  31. package/dist/r2-upload.js +93 -0
  32. package/dist/rest-register.js +50 -0
  33. package/dist/upload-session.js +71 -0
  34. package/game-2d-template/index.html.tpl +18 -0
  35. package/game-2d-template/manifest.json.tpl +6 -0
  36. package/game-2d-template/package.json.tpl +23 -0
  37. package/game-2d-template/src/main.ts +49 -0
  38. package/game-2d-template/src/vite-env.d.ts +1 -0
  39. package/game-2d-template/tsconfig.json +12 -0
  40. package/game-2d-template/vite.config.ts +6 -0
  41. package/package.json +43 -0
@@ -0,0 +1,46 @@
1
+ /**
2
+ * harness page HTML template。 CLI dev-server の `/` が返す静的 HTML。
3
+ * client bundle (`/_uzu_harness.js`) と meta (`/_uzu_meta.json`) をロードして
4
+ * iframe grid を描画する薄いシェル。
5
+ */
6
+ export function harnessHtml() {
7
+ return `<!DOCTYPE html>
8
+ <html lang="ja">
9
+ <head>
10
+ <meta charset="utf-8" />
11
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
12
+ <meta name="apple-mobile-web-app-capable" content="yes" />
13
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
14
+ <meta name="apple-mobile-web-app-title" content="UZU dev" />
15
+ <title>UZU dev harness</title>
16
+ <style>
17
+ html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #1a1a2e; overflow: hidden; }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <script src="/_uzu_harness.js"></script>
22
+ </body>
23
+ </html>
24
+ `;
25
+ }
26
+ /**
27
+ * client bundle を esbuild で IIFE 形式にビルドして文字列で返す。
28
+ * cli.ts 起動時に 1 度だけ呼ぶ。 mount / HUD / admin client を含む。
29
+ */
30
+ export async function buildHarnessClientJs(entryFile) {
31
+ const esbuild = await import('esbuild');
32
+ const result = await esbuild.build({
33
+ entryPoints: [entryFile],
34
+ bundle: true,
35
+ format: 'iife',
36
+ target: 'es2022',
37
+ platform: 'browser',
38
+ write: false,
39
+ minify: false,
40
+ });
41
+ const out = result.outputFiles?.[0];
42
+ if (!out) {
43
+ throw new Error('esbuild produced no output for harness client bundle');
44
+ }
45
+ return out.text;
46
+ }
@@ -0,0 +1,93 @@
1
+ import { readFileSync } from 'fs';
2
+ import { readdir } from 'fs/promises';
3
+ import mime from 'mime-types';
4
+ import { basename, join, relative, sep } from 'path';
5
+ import { createUploadSession, putToPresignedURL } from './upload-session.js';
6
+ // mime-types の contentType() は '/' を含む文字列を「既に MIME type 済み」とみなしてそのまま返す仕様。
7
+ // サブディレクトリ込みのパスを渡すとパスがそのまま Content-Type ヘッダーになってしまうので、
8
+ // basename だけを渡して拡張子から判定させる。
9
+ const getMimeType = (path) => mime.contentType(basename(path)) || 'application/octet-stream';
10
+ const UPLOAD_CONCURRENCY = 16;
11
+ const listFilesRecursively = async (rootDir) => {
12
+ const entries = await readdir(rootDir, { withFileTypes: true, recursive: true });
13
+ return entries.filter((e) => e.isFile()).map((e) => join(e.parentPath, e.name));
14
+ };
15
+ const runWithConcurrency = async (items, concurrency, worker) => {
16
+ let index = 0;
17
+ const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
18
+ while (true) {
19
+ const current = index++;
20
+ if (current >= items.length)
21
+ return;
22
+ await worker(items[current]);
23
+ }
24
+ });
25
+ await Promise.all(runners);
26
+ };
27
+ /**
28
+ * ゲームの ZIP と個別ファイルを R2 にアップロードする。
29
+ *
30
+ * ストレージ形式:
31
+ * - {revisionId}/data.zip — モバイル `/__zip__` 経路の互換維持用
32
+ * - {revisionId}/files/{relPath} — Worker から個別配信する用
33
+ *
34
+ * Worker のメモリ上限 (128MB) を超えないよう、Worker 側で ZIP を展開せずに
35
+ * R2 から直接個別ファイルを返す前提で publish 時に展開済みオブジェクトを並べておく。
36
+ *
37
+ * revisionId は backend が採番する。返る token は logic.js / アイコンの追加発行と
38
+ * revision 登録の証明に使う。
39
+ */
40
+ export const uploadGameToR2 = async (env, zipPath, outputDir) => {
41
+ const filePaths = await listFilesRecursively(outputDir);
42
+ // R2 key は POSIX 区切り(/)に正規化する
43
+ const files = filePaths.map((absPath) => ({
44
+ absPath,
45
+ relPath: relative(outputDir, absPath).split(sep).join('/'),
46
+ }));
47
+ const session = await createUploadSession({
48
+ env,
49
+ files: [
50
+ { path: 'data.zip', contentType: 'application/zip' },
51
+ ...files.map((f) => ({ path: `files/${f.relPath}`, contentType: getMimeType(f.relPath) })),
52
+ ],
53
+ });
54
+ const uploadByPath = new Map(session.uploads.map((u) => [u.path, u]));
55
+ const uploadFor = (path) => {
56
+ const upload = uploadByPath.get(path);
57
+ if (!upload)
58
+ throw new Error(`アップロード URL が発行されていません: ${path}`);
59
+ return upload;
60
+ };
61
+ // 1. ZIP を data.zip としてアップロード(モバイル `/__zip__` 経路用)
62
+ await putToPresignedURL(uploadFor('data.zip'), readFileSync(zipPath));
63
+ // 2. 個別ファイルを files/ 配下に並列アップロード(Web emulator 経路用)
64
+ console.log(`Uploading ${files.length} files to R2 (concurrency=${UPLOAD_CONCURRENCY})...`);
65
+ let uploaded = 0;
66
+ await runWithConcurrency(files, UPLOAD_CONCURRENCY, async (f) => {
67
+ await putToPresignedURL(uploadFor(`files/${f.relPath}`), readFileSync(f.absPath));
68
+ uploaded++;
69
+ if (uploaded % 50 === 0 || uploaded === files.length) {
70
+ console.log(` ${uploaded}/${files.length}`);
71
+ }
72
+ });
73
+ return { resourceId: session.resourceId, revisionId: session.revisionId, token: session.token };
74
+ };
75
+ /**
76
+ * ゲーム固有の logic.js を R2 にアップロードする。
77
+ * ストレージ形式: {revisionId}/logic.js
78
+ *
79
+ * ランタイムテンプレ(GameRoom エンジン)は v3-play-server が所有し、JIT デプロイ時に
80
+ * この logic.js と合成される。R2 にはゲーム固有の logic のみを置く(テンプレは焼き込まない)。
81
+ */
82
+ export const uploadLogicToR2 = async (env, token, logicJs) => {
83
+ const session = await createUploadSession({
84
+ env,
85
+ token,
86
+ files: [{ path: 'logic.js', contentType: 'application/javascript' }],
87
+ });
88
+ if (session.uploads.length !== 1) {
89
+ throw new Error('logic.js のアップロード URL が発行されていません');
90
+ }
91
+ await putToPresignedURL(session.uploads[0], logicJs);
92
+ console.log(`Uploaded logic.js to R2: ${session.revisionId}/logic.js`);
93
+ };
@@ -0,0 +1,50 @@
1
+ import { graphBaseURL } from './auth/env.js';
2
+ import { getValidIdToken } from './auth/token-cache.js';
3
+ export const buildCharacters = (params) => params.characters
4
+ ? params.characters.map((char) => ({
5
+ id: char.id,
6
+ name: char.name,
7
+ description: char.description ?? '',
8
+ iconPath: char.icon ?? '',
9
+ ...(char.furigana ? { furigana: char.furigana } : {}),
10
+ }))
11
+ : Array.from({ length: params.playerCount }, (_, i) => ({
12
+ id: `player${i + 1}`,
13
+ name: `Player ${i + 1}`,
14
+ description: '',
15
+ iconPath: `https://ui-avatars.com/api/?name=Player+${i + 1}&background=random&size=128`,
16
+ }));
17
+ export const parseRegisterResponse = (body) => {
18
+ if (typeof body === 'object' &&
19
+ body !== null &&
20
+ 'scenarioRevisionId' in body &&
21
+ typeof body.scenarioRevisionId === 'number') {
22
+ return body.scenarioRevisionId;
23
+ }
24
+ throw new Error('scenarioRevisionId を含まない応答を受け取りました');
25
+ };
26
+ export const registerRevision = async (params) => {
27
+ // 認証必須。未ログインならここで分かりやすいエラーを出して中断する。
28
+ const idToken = await getValidIdToken(params.env);
29
+ const res = await fetch(`${graphBaseURL(params.env)}/uzu_code/scenario_revisions`, {
30
+ method: 'POST',
31
+ headers: {
32
+ authorization: `bearer ${idToken}`,
33
+ 'content-type': 'application/json',
34
+ },
35
+ body: JSON.stringify({
36
+ scenarioId: params.gameId,
37
+ configResourceId: params.resourceId,
38
+ uploadToken: params.uploadToken,
39
+ configSchemaVersion: '3.0.0',
40
+ changeNotes: params.changeNotes,
41
+ orientation: params.orientation,
42
+ characters: buildCharacters(params),
43
+ }),
44
+ });
45
+ if (!res.ok) {
46
+ const detail = (await res.text()).trim();
47
+ throw new Error(`revision の登録に失敗しました (HTTP ${res.status}): ${detail}`);
48
+ }
49
+ return parseRegisterResponse(await res.json());
50
+ };
@@ -0,0 +1,71 @@
1
+ import { graphBaseURL } from './auth/env.js';
2
+ import { getValidIdToken } from './auth/token-cache.js';
3
+ const isUpload = (v) => typeof v === 'object' &&
4
+ v !== null &&
5
+ 'path' in v &&
6
+ typeof v.path === 'string' &&
7
+ 'url' in v &&
8
+ typeof v.url === 'string' &&
9
+ 'contentType' in v &&
10
+ typeof v.contentType === 'string';
11
+ const isImage = (v) => typeof v === 'object' && v !== null && 'uploadURL' in v && typeof v.uploadURL === 'string';
12
+ export const parseUploadSession = (v) => {
13
+ if (typeof v === 'object' &&
14
+ v !== null &&
15
+ 'resourceId' in v &&
16
+ typeof v.resourceId === 'string' &&
17
+ 'revisionId' in v &&
18
+ typeof v.revisionId === 'string' &&
19
+ 'token' in v &&
20
+ typeof v.token === 'string' &&
21
+ 'uploads' in v &&
22
+ Array.isArray(v.uploads) &&
23
+ v.uploads.every(isUpload) &&
24
+ 'images' in v &&
25
+ Array.isArray(v.images) &&
26
+ v.images.every(isImage) &&
27
+ 'imageDeliveryBaseURL' in v &&
28
+ typeof v.imageDeliveryBaseURL === 'string') {
29
+ return {
30
+ resourceId: v.resourceId,
31
+ revisionId: v.revisionId,
32
+ token: v.token,
33
+ uploads: v.uploads,
34
+ images: v.images,
35
+ imageDeliveryBaseURL: v.imageDeliveryBaseURL,
36
+ };
37
+ }
38
+ throw new Error('アップロードセッションの応答が想定外の形式です');
39
+ };
40
+ export const createUploadSession = async (params) => {
41
+ const idToken = await getValidIdToken(params.env);
42
+ const res = await fetch(`${graphBaseURL(params.env)}/uzu_code/upload_sessions`, {
43
+ method: 'POST',
44
+ headers: {
45
+ authorization: `bearer ${idToken}`,
46
+ 'content-type': 'application/json',
47
+ },
48
+ body: JSON.stringify({
49
+ ...(params.token ? { token: params.token } : {}),
50
+ files: params.files ?? [],
51
+ imageCount: params.imageCount ?? 0,
52
+ }),
53
+ });
54
+ if (!res.ok) {
55
+ const detail = (await res.text()).trim();
56
+ throw new Error(`アップロードセッションの発行に失敗しました (HTTP ${res.status}): ${detail}`);
57
+ }
58
+ return parseUploadSession(await res.json());
59
+ };
60
+ /** presigned PUT URL へファイル内容をアップロードする。 */
61
+ export const putToPresignedURL = async (upload, body) => {
62
+ const res = await fetch(upload.url, {
63
+ method: 'PUT',
64
+ headers: { 'content-type': upload.contentType },
65
+ body: typeof body === 'string' ? body : new Blob([body]),
66
+ });
67
+ if (!res.ok) {
68
+ const detail = (await res.text()).trim();
69
+ throw new Error(`アップロードに失敗しました (${upload.path}, HTTP ${res.status}): ${detail}`);
70
+ }
71
+ };
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{{title}}</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ html, body { width: 100%; height: 100%; overflow: hidden; }
10
+ body { background: #0f172a; display: flex; justify-content: center; align-items: center; }
11
+ canvas { width: 100%; height: 100%; display: block; }
12
+ </style>
13
+ </head>
14
+ <body>
15
+ <canvas id="canvas"></canvas>
16
+ <script type="module" src="/src/main.ts"></script>
17
+ </body>
18
+ </html>
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "{{name}}",
3
+ "build": "npm run build",
4
+ "output": "dist",
5
+ "playerCount": 1
6
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "harness": "uzu dev",
9
+ "build": "tsc && vite build",
10
+ "zip": "npm run build && cd dist && zip -r ../game.zip .",
11
+ "publish": "uzu publish",
12
+ "preview": "vite preview"
13
+ },
14
+ "dependencies": {
15
+ "@uzupj/engine-2d": "*",
16
+ "@uzuhq/code-sdk": "*"
17
+ },
18
+ "devDependencies": {
19
+ "@uzuhq/code-cli": "*",
20
+ "typescript": "^5.7.0",
21
+ "vite": "^6.0.0"
22
+ }
23
+ }
@@ -0,0 +1,49 @@
1
+ import { createEngine } from '@uzupj/engine-2d';
2
+ import { init } from '@uzuhq/code-sdk';
3
+
4
+ // SDK 初期化
5
+ init();
6
+
7
+ // エンジン作成
8
+ const canvas = document.getElementById('canvas') as HTMLCanvasElement;
9
+ const engine = createEngine({
10
+ canvas,
11
+ background: '#1a1a2e',
12
+ });
13
+
14
+ const W = engine.width;
15
+ const H = engine.height;
16
+ let score = 0;
17
+
18
+ engine.addMany([
19
+ {
20
+ id: 'score',
21
+ type: 'text',
22
+ x: W / 2,
23
+ y: 30,
24
+ text: '0',
25
+ font: 'bold 24px sans-serif',
26
+ fill: '#fff',
27
+ align: 'center',
28
+ },
29
+ {
30
+ id: 'target',
31
+ type: 'circle',
32
+ x: W / 2,
33
+ y: H / 2,
34
+ radius: 30,
35
+ fill: '#ef4444',
36
+ onClick: () => {
37
+ score++;
38
+ engine.update('score', { text: String(score) });
39
+ engine.animate(
40
+ 'target',
41
+ {
42
+ x: 50 + Math.random() * (W - 100),
43
+ y: 100 + Math.random() * (H - 200),
44
+ },
45
+ { duration: 200, easing: 'easeOutBack' },
46
+ );
47
+ },
48
+ },
49
+ ]);
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist"
10
+ },
11
+ "include": ["src"]
12
+ }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ base: './',
3
+ server: {
4
+ cors: true,
5
+ },
6
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@uzuhq/code-cli",
3
+ "version": "0.3.14",
4
+ "description": "UZU ゲーム開発 CLI - ビルド・パブリッシュ・プロジェクト作成ツール",
5
+ "type": "module",
6
+ "bin": {
7
+ "uzu": "./dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "game-2d-template"
12
+ ],
13
+ "license": "MIT",
14
+ "homepage": "https://uzu-app.com",
15
+ "publishConfig": {
16
+ "registry": "https://registry.npmjs.org",
17
+ "access": "public"
18
+ },
19
+ "dependencies": {
20
+ "archiver": "^8.0.0",
21
+ "commander": "^15.0.0",
22
+ "esbuild": "^0.28.1",
23
+ "mime-types": "^3.0.2",
24
+ "qrcode-generator": "^2.0.4",
25
+ "ws": "^8.18.0"
26
+ },
27
+ "devDependencies": {
28
+ "@cloudflare/workers-types": "^4.20260702.1",
29
+ "@types/archiver": "^8.0.0",
30
+ "@types/mime-types": "^3.0.1",
31
+ "@types/node": "^22.19.21",
32
+ "@types/ws": "^8.5.14",
33
+ "tsx": "^4.23.0",
34
+ "typescript": "^6.0.0"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc",
38
+ "watch": "tsc --watch",
39
+ "dev": "tsx src/cli.ts",
40
+ "lint": "tsc --noEmit -p tsconfig.lint.json",
41
+ "test": "tsx --test \"src/**/*.test.ts\""
42
+ }
43
+ }