@playcraft/cli 0.0.1

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 (37) hide show
  1. package/README.md +12 -0
  2. package/dist/build-config.js +26 -0
  3. package/dist/commands/build.js +363 -0
  4. package/dist/commands/config.js +133 -0
  5. package/dist/commands/init.js +86 -0
  6. package/dist/commands/inspect.js +209 -0
  7. package/dist/commands/logs.js +121 -0
  8. package/dist/commands/start.js +284 -0
  9. package/dist/commands/status.js +106 -0
  10. package/dist/commands/stop.js +58 -0
  11. package/dist/config.js +31 -0
  12. package/dist/fs-handler.js +83 -0
  13. package/dist/index.js +200 -0
  14. package/dist/logger.js +122 -0
  15. package/dist/playable/base-builder.js +265 -0
  16. package/dist/playable/builder.js +1462 -0
  17. package/dist/playable/converter.js +150 -0
  18. package/dist/playable/index.js +3 -0
  19. package/dist/playable/platforms/base.js +12 -0
  20. package/dist/playable/platforms/facebook.js +37 -0
  21. package/dist/playable/platforms/index.js +24 -0
  22. package/dist/playable/platforms/snapchat.js +59 -0
  23. package/dist/playable/playable-builder.js +521 -0
  24. package/dist/playable/types.js +1 -0
  25. package/dist/playable/vite/config-builder.js +136 -0
  26. package/dist/playable/vite/platform-configs.js +102 -0
  27. package/dist/playable/vite/plugin-model-compression.js +63 -0
  28. package/dist/playable/vite/plugin-platform.js +65 -0
  29. package/dist/playable/vite/plugin-playcanvas.js +454 -0
  30. package/dist/playable/vite-builder.js +125 -0
  31. package/dist/port-utils.js +27 -0
  32. package/dist/process-manager.js +96 -0
  33. package/dist/server.js +128 -0
  34. package/dist/socket.js +117 -0
  35. package/dist/watcher.js +33 -0
  36. package/package.json +41 -0
  37. package/templates/playable-ad.html +59 -0
@@ -0,0 +1,125 @@
1
+ import { build as viteBuild } from 'vite';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { ViteConfigBuilder } from './vite/config-builder.js';
5
+ import { PLATFORM_CONFIGS } from './vite/platform-configs.js';
6
+ /**
7
+ * Vite 构建器 - 使用 Vite 构建 Playable Ads
8
+ *
9
+ * 职责:
10
+ * 1. 验证输入是有效的基础构建
11
+ * 2. 创建 Vite 配置
12
+ * 3. 执行 Vite 构建
13
+ * 4. 验证输出大小
14
+ * 5. 生成报告
15
+ */
16
+ export class ViteBuilder {
17
+ baseBuildDir;
18
+ options;
19
+ sizeReport;
20
+ constructor(baseBuildDir, options) {
21
+ this.baseBuildDir = baseBuildDir;
22
+ this.options = options;
23
+ const platformConfig = PLATFORM_CONFIGS[options.platform];
24
+ this.sizeReport = {
25
+ engine: 0,
26
+ assets: {},
27
+ total: 0,
28
+ limit: platformConfig.sizeLimit,
29
+ };
30
+ }
31
+ /**
32
+ * 执行构建
33
+ */
34
+ async build() {
35
+ // 1. 验证输入
36
+ await this.validateBaseBuild();
37
+ // 2. 创建 Vite 配置
38
+ const configBuilder = new ViteConfigBuilder(this.baseBuildDir, this.options.platform, this.options);
39
+ const viteConfig = configBuilder.create();
40
+ // 3. 执行 Vite 构建
41
+ await viteBuild(viteConfig);
42
+ // 4. 验证输出大小
43
+ const outputPath = this.getOutputPath();
44
+ await this.validateSize(outputPath);
45
+ // 5. 生成报告
46
+ this.generateReport(outputPath);
47
+ return outputPath;
48
+ }
49
+ /**
50
+ * 验证基础构建
51
+ */
52
+ async validateBaseBuild() {
53
+ const requiredFiles = [
54
+ 'index.html',
55
+ 'config.json',
56
+ '__start__.js',
57
+ ];
58
+ const missingFiles = [];
59
+ for (const file of requiredFiles) {
60
+ try {
61
+ await fs.access(path.join(this.baseBuildDir, file));
62
+ }
63
+ catch (error) {
64
+ missingFiles.push(file);
65
+ }
66
+ }
67
+ if (missingFiles.length > 0) {
68
+ throw new Error(`基础构建产物缺少必需文件: ${missingFiles.join(', ')}\n` +
69
+ `请确保输入目录包含完整的多文件构建产物。`);
70
+ }
71
+ }
72
+ /**
73
+ * 获取输出路径
74
+ */
75
+ getOutputPath() {
76
+ const platformConfig = PLATFORM_CONFIGS[this.options.platform];
77
+ const outputDir = this.options.outputDir || './dist';
78
+ if (platformConfig.outputFormat === 'zip') {
79
+ return path.join(outputDir, 'playable.zip');
80
+ }
81
+ return path.join(outputDir, platformConfig.outputFileName);
82
+ }
83
+ /**
84
+ * 验证输出大小
85
+ */
86
+ async validateSize(outputPath) {
87
+ try {
88
+ const stats = await fs.stat(outputPath);
89
+ this.sizeReport.total = stats.size;
90
+ this.sizeReport.assets[path.basename(outputPath)] = stats.size;
91
+ if (stats.size > this.sizeReport.limit) {
92
+ const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
93
+ const limitMB = (this.sizeReport.limit / 1024 / 1024).toFixed(2);
94
+ console.warn(`⚠️ 警告: 文件大小 ${sizeMB} MB 超过限制 ${limitMB} MB`);
95
+ }
96
+ }
97
+ catch (error) {
98
+ console.warn(`警告: 无法读取输出文件: ${outputPath}`);
99
+ }
100
+ }
101
+ /**
102
+ * 生成报告
103
+ */
104
+ generateReport(outputPath) {
105
+ // 报告已在 validateSize 中生成
106
+ // 这里可以添加额外的报告逻辑
107
+ }
108
+ /**
109
+ * 获取大小报告
110
+ */
111
+ getSizeReport() {
112
+ return this.sizeReport;
113
+ }
114
+ /**
115
+ * 格式化字节数
116
+ */
117
+ formatBytes(bytes) {
118
+ if (bytes === 0)
119
+ return '0 Bytes';
120
+ const k = 1024;
121
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
122
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
123
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
124
+ }
125
+ }
@@ -0,0 +1,27 @@
1
+ import { createServer } from 'net';
2
+ export async function isPortAvailable(port) {
3
+ return new Promise((resolve) => {
4
+ const server = createServer();
5
+ server.listen(port, () => {
6
+ server.once('close', () => resolve(true));
7
+ server.close();
8
+ });
9
+ server.on('error', (err) => {
10
+ if (err.code === 'EADDRINUSE') {
11
+ resolve(false);
12
+ }
13
+ else {
14
+ resolve(false);
15
+ }
16
+ });
17
+ });
18
+ }
19
+ export async function findAvailablePort(startPort, maxAttempts = 100) {
20
+ for (let i = 0; i < maxAttempts; i++) {
21
+ const port = startPort + i;
22
+ if (await isPortAvailable(port)) {
23
+ return port;
24
+ }
25
+ }
26
+ throw new Error(`Could not find an available port starting from ${startPort}`);
27
+ }
@@ -0,0 +1,96 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ const PLAYCRAFT_DIR = path.join(os.homedir(), '.playcraft');
5
+ const PIDS_DIR = path.join(PLAYCRAFT_DIR, 'pids');
6
+ export class ProcessManager {
7
+ static async ensureDirectories() {
8
+ try {
9
+ await fs.mkdir(PLAYCRAFT_DIR, { recursive: true });
10
+ await fs.mkdir(PIDS_DIR, { recursive: true });
11
+ }
12
+ catch (error) {
13
+ if (error.code !== 'EEXIST') {
14
+ throw new Error(`Failed to create directories: ${error.message}`);
15
+ }
16
+ }
17
+ }
18
+ static getPidFilePath(projectId) {
19
+ return path.join(PIDS_DIR, `${projectId}.pid`);
20
+ }
21
+ static async savePid(projectId, pid) {
22
+ await this.ensureDirectories();
23
+ const pidFile = this.getPidFilePath(projectId);
24
+ await fs.writeFile(pidFile, pid.toString(), 'utf-8');
25
+ }
26
+ static async loadPid(projectId) {
27
+ try {
28
+ const pidFile = this.getPidFilePath(projectId);
29
+ const content = await fs.readFile(pidFile, 'utf-8');
30
+ const pid = parseInt(content.trim(), 10);
31
+ return isNaN(pid) ? null : pid;
32
+ }
33
+ catch (error) {
34
+ if (error.code === 'ENOENT') {
35
+ return null;
36
+ }
37
+ throw error;
38
+ }
39
+ }
40
+ static async removePid(projectId) {
41
+ try {
42
+ const pidFile = this.getPidFilePath(projectId);
43
+ await fs.unlink(pidFile);
44
+ }
45
+ catch (error) {
46
+ if (error.code !== 'ENOENT') {
47
+ throw error;
48
+ }
49
+ }
50
+ }
51
+ static isRunning(pid) {
52
+ try {
53
+ // 发送信号 0 来检查进程是否存在(不会实际发送信号)
54
+ process.kill(pid, 0);
55
+ return true;
56
+ }
57
+ catch (error) {
58
+ // ESRCH 表示进程不存在
59
+ return error.code !== 'ESRCH';
60
+ }
61
+ }
62
+ static async killProcess(pid, signal = 'SIGTERM') {
63
+ if (!this.isRunning(pid)) {
64
+ throw new Error(`Process ${pid} is not running`);
65
+ }
66
+ try {
67
+ process.kill(pid, signal);
68
+ }
69
+ catch (error) {
70
+ throw new Error(`Failed to kill process ${pid}: ${error.message}`);
71
+ }
72
+ }
73
+ static async getAllPids() {
74
+ await this.ensureDirectories();
75
+ try {
76
+ const files = await fs.readdir(PIDS_DIR);
77
+ const pids = [];
78
+ for (const file of files) {
79
+ if (file.endsWith('.pid')) {
80
+ const projectId = file.replace('.pid', '');
81
+ const pid = await this.loadPid(projectId);
82
+ if (pid !== null) {
83
+ pids.push({ projectId, pid });
84
+ }
85
+ }
86
+ }
87
+ return pids;
88
+ }
89
+ catch (error) {
90
+ if (error.code === 'ENOENT') {
91
+ return [];
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+ }
package/dist/server.js ADDED
@@ -0,0 +1,128 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ export function createServer(config, fsHandler) {
4
+ const app = express();
5
+ app.use(cors());
6
+ app.use(express.json());
7
+ // Health check
8
+ app.get('/health', (req, res) => {
9
+ res.json({ status: 'ok', projectId: config.projectId });
10
+ });
11
+ // Get file content
12
+ app.get('/file', async (req, res) => {
13
+ const filePath = req.query.path;
14
+ if (!filePath) {
15
+ return res.status(400).json({ error: 'Missing path parameter' });
16
+ }
17
+ try {
18
+ const result = await fsHandler.readFile(filePath);
19
+ if (result.exists) {
20
+ res.json(result);
21
+ }
22
+ else {
23
+ res.status(404).json({ error: 'File not found' });
24
+ }
25
+ }
26
+ catch (error) {
27
+ res.status(500).json({ error: error.message });
28
+ }
29
+ });
30
+ // List directory
31
+ app.get('/list', async (req, res) => {
32
+ const dirPath = req.query.path || '.';
33
+ try {
34
+ const entries = await fsHandler.listDirectory(dirPath);
35
+ res.json({ entries });
36
+ }
37
+ catch (error) {
38
+ res.status(500).json({ error: error.message });
39
+ }
40
+ });
41
+ // Get project info
42
+ app.get('/info', (req, res) => {
43
+ res.json({
44
+ projectId: config.projectId,
45
+ version: '1.0.0',
46
+ dir: config.dir,
47
+ port: config.port,
48
+ });
49
+ });
50
+ // Get binary file
51
+ app.get('/file/binary', async (req, res) => {
52
+ const filePath = req.query.path;
53
+ if (!filePath) {
54
+ return res.status(400).json({ error: 'Missing path parameter' });
55
+ }
56
+ try {
57
+ const result = await fsHandler.readBinaryFile(filePath);
58
+ if (result.exists) {
59
+ // Set appropriate content type
60
+ const ext = filePath.split('.').pop()?.toLowerCase();
61
+ const contentType = getContentType(ext || '');
62
+ res.setHeader('Content-Type', contentType);
63
+ if (result.size !== undefined) {
64
+ res.setHeader('Content-Length', result.size);
65
+ }
66
+ res.send(result.buffer);
67
+ }
68
+ else {
69
+ res.status(404).json({ error: 'File not found' });
70
+ }
71
+ }
72
+ catch (error) {
73
+ res.status(500).json({ error: error.message });
74
+ }
75
+ });
76
+ // Batch get files
77
+ app.post('/files', async (req, res) => {
78
+ const { paths } = req.body;
79
+ if (!Array.isArray(paths)) {
80
+ return res.status(400).json({ error: 'paths must be an array' });
81
+ }
82
+ try {
83
+ const files = await Promise.all(paths.map(async (filePath) => {
84
+ try {
85
+ const result = await fsHandler.readFile(filePath);
86
+ return {
87
+ path: filePath,
88
+ exists: result.exists,
89
+ content: result.exists ? result.content : null,
90
+ mtime: result.exists ? result.mtime : null,
91
+ size: result.exists ? result.size : null,
92
+ };
93
+ }
94
+ catch (error) {
95
+ return {
96
+ path: filePath,
97
+ exists: false,
98
+ error: error.message,
99
+ };
100
+ }
101
+ }));
102
+ res.json({ files });
103
+ }
104
+ catch (error) {
105
+ res.status(500).json({ error: error.message });
106
+ }
107
+ });
108
+ return app;
109
+ }
110
+ function getContentType(ext) {
111
+ const contentTypes = {
112
+ png: 'image/png',
113
+ jpg: 'image/jpeg',
114
+ jpeg: 'image/jpeg',
115
+ gif: 'image/gif',
116
+ webp: 'image/webp',
117
+ hdr: 'image/vnd.radiance',
118
+ glb: 'model/gltf-binary',
119
+ gltf: 'model/gltf+json',
120
+ fbx: 'application/octet-stream',
121
+ mp3: 'audio/mpeg',
122
+ wav: 'audio/wav',
123
+ ogg: 'audio/ogg',
124
+ mp4: 'video/mp4',
125
+ webm: 'video/webm',
126
+ };
127
+ return contentTypes[ext] || 'application/octet-stream';
128
+ }
package/dist/socket.js ADDED
@@ -0,0 +1,117 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ export class SocketServer {
3
+ config;
4
+ wss;
5
+ clients = new Set();
6
+ pingInterval = null;
7
+ onConnectionChange;
8
+ constructor(server, config, onConnectionChange) {
9
+ this.config = config;
10
+ this.wss = new WebSocketServer({ server });
11
+ this.onConnectionChange = onConnectionChange;
12
+ this.wss.on('connection', (ws) => {
13
+ this.clients.add(ws);
14
+ // Notify connection change
15
+ if (this.onConnectionChange) {
16
+ this.onConnectionChange(this.clients.size);
17
+ }
18
+ // Handle incoming messages
19
+ ws.on('message', (data) => {
20
+ try {
21
+ const message = JSON.parse(data.toString());
22
+ this.handleMessage(ws, message);
23
+ }
24
+ catch (error) {
25
+ // Ignore invalid JSON
26
+ }
27
+ });
28
+ ws.on('close', () => {
29
+ this.clients.delete(ws);
30
+ // Notify connection change
31
+ if (this.onConnectionChange) {
32
+ this.onConnectionChange(this.clients.size);
33
+ }
34
+ });
35
+ // Send initial hello
36
+ ws.send(JSON.stringify({
37
+ type: 'hello',
38
+ payload: {
39
+ projectId: this.config.projectId,
40
+ version: '1.0.0'
41
+ }
42
+ }));
43
+ // Send connection status
44
+ this.broadcast('connection_status', { connected: true });
45
+ });
46
+ // Start ping interval
47
+ this.startPingInterval();
48
+ }
49
+ handleMessage(ws, message) {
50
+ switch (message.type) {
51
+ case 'ping':
52
+ ws.send(JSON.stringify({ type: 'pong', payload: message.payload }));
53
+ break;
54
+ case 'subscribe':
55
+ // Store subscription patterns for this client (simplified implementation)
56
+ // In a full implementation, we would filter file change notifications
57
+ ws.send(JSON.stringify({
58
+ type: 'subscribed',
59
+ payload: { patterns: message.payload?.patterns || [] }
60
+ }));
61
+ break;
62
+ }
63
+ }
64
+ startPingInterval() {
65
+ if (this.pingInterval) {
66
+ clearInterval(this.pingInterval);
67
+ }
68
+ this.pingInterval = setInterval(() => {
69
+ for (const client of this.clients) {
70
+ if (client.readyState === WebSocket.OPEN) {
71
+ try {
72
+ client.send(JSON.stringify({ type: 'ping', payload: Date.now() }));
73
+ }
74
+ catch (error) {
75
+ // Ignore send errors
76
+ }
77
+ }
78
+ }
79
+ }, 30000); // Ping every 30 seconds
80
+ }
81
+ destroy() {
82
+ if (this.pingInterval) {
83
+ clearInterval(this.pingInterval);
84
+ this.pingInterval = null;
85
+ }
86
+ // Close all WebSocket connections
87
+ for (const client of this.clients) {
88
+ try {
89
+ client.close();
90
+ }
91
+ catch (error) {
92
+ // Ignore errors
93
+ }
94
+ }
95
+ this.clients.clear();
96
+ // Close WebSocket server
97
+ this.wss.close();
98
+ }
99
+ broadcast(type, payload) {
100
+ const message = JSON.stringify({ type, payload });
101
+ for (const client of this.clients) {
102
+ if (client.readyState === WebSocket.OPEN) {
103
+ client.send(message);
104
+ }
105
+ }
106
+ }
107
+ notifyFileChange(path, changeType) {
108
+ this.broadcast('file_changed', {
109
+ path,
110
+ changeType,
111
+ timestamp: new Date().toISOString()
112
+ });
113
+ }
114
+ getConnectionCount() {
115
+ return this.clients.size;
116
+ }
117
+ }
@@ -0,0 +1,33 @@
1
+ import { watch } from 'chokidar';
2
+ import path from 'path';
3
+ export class Watcher {
4
+ config;
5
+ onChange;
6
+ watcher;
7
+ constructor(config, onChange) {
8
+ this.config = config;
9
+ this.onChange = onChange;
10
+ this.watcher = watch(this.config.dir, {
11
+ ignored: [
12
+ '**/node_modules/**',
13
+ '**/.git/**',
14
+ '**/dist/**',
15
+ '**/playcraft.agent.config.json',
16
+ '**/.DS_Store'
17
+ ],
18
+ persistent: true,
19
+ ignoreInitial: true,
20
+ });
21
+ this.watcher
22
+ .on('add', (filePath) => this.handleEvent(filePath, 'add'))
23
+ .on('change', (filePath) => this.handleEvent(filePath, 'modify'))
24
+ .on('unlink', (filePath) => this.handleEvent(filePath, 'delete'));
25
+ }
26
+ handleEvent(filePath, type) {
27
+ const relativePath = path.relative(this.config.dir, filePath);
28
+ this.onChange(relativePath, type);
29
+ }
30
+ close() {
31
+ return this.watcher.close();
32
+ }
33
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@playcraft/cli",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "bin": {
7
+ "playcraft": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "templates",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "dev": "tsc -w",
16
+ "build": "tsc",
17
+ "start": "node dist/index.js"
18
+ },
19
+ "dependencies": {
20
+ "@playcraft/build": "workspace:*",
21
+ "chokidar": "^4.0.3",
22
+ "commander": "^13.1.0",
23
+ "cors": "^2.8.5",
24
+ "cosmiconfig": "^9.0.0",
25
+ "dotenv": "^17.2.3",
26
+ "express": "^5.2.1",
27
+ "inquirer": "^9.2.12",
28
+ "ora": "^8.2.0",
29
+ "picocolors": "^1.1.1",
30
+ "ws": "^8.18.0",
31
+ "zod": "^3.24.1"
32
+ },
33
+ "devDependencies": {
34
+ "@types/cors": "^2.8.17",
35
+ "@types/express": "^5.0.0",
36
+ "@types/inquirer": "^9.0.7",
37
+ "@types/node": "^22.10.5",
38
+ "@types/ws": "^8.5.13",
39
+ "typescript": "^5.7.2"
40
+ }
41
+ }
@@ -0,0 +1,59 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
+ <title>Playable Ad</title>
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+ html, body {
16
+ width: 100%;
17
+ height: 100%;
18
+ overflow: hidden;
19
+ background: #000;
20
+ touch-action: none;
21
+ -webkit-touch-callout: none;
22
+ -webkit-user-select: none;
23
+ user-select: none;
24
+ }
25
+ #application-canvas {
26
+ display: block;
27
+ width: 100%;
28
+ height: 100%;
29
+ margin: 0 auto;
30
+ image-rendering: -webkit-optimize-contrast;
31
+ image-rendering: crisp-edges;
32
+ }
33
+ /* 加载指示器 */
34
+ #loading {
35
+ position: absolute;
36
+ top: 50%;
37
+ left: 50%;
38
+ transform: translate(-50%, -50%);
39
+ color: #fff;
40
+ font-family: Arial, sans-serif;
41
+ font-size: 14px;
42
+ }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <div id="loading">Loading...</div>
47
+ <canvas id="application-canvas"></canvas>
48
+ <!-- PlayCanvas Engine, Config 和 Start Script 将在这里注入 -->
49
+ <script>
50
+ // 隐藏加载指示器
51
+ window.addEventListener('load', function() {
52
+ const loading = document.getElementById('loading');
53
+ if (loading) {
54
+ loading.style.display = 'none';
55
+ }
56
+ });
57
+ </script>
58
+ </body>
59
+ </html>