@playcraft/cli 0.0.11 → 0.0.13
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/dist/agent/agent.js +202 -0
- package/dist/agent/api-proxy.js +68 -0
- package/dist/agent/cloud-connection.js +233 -0
- package/dist/agent/cloud-connection.test.js +67 -0
- package/dist/agent/fs-backend.js +158 -0
- package/dist/agent/local-backend.js +359 -0
- package/dist/agent/local-backend.test.js +52 -0
- package/dist/commands/build.js +167 -46
- package/dist/commands/fix-ids.js +43 -0
- package/dist/commands/start.js +14 -129
- package/dist/commands/sync.js +40 -0
- package/dist/commands/upgrade.js +71 -0
- package/dist/config.js +2 -0
- package/dist/fs-handler.js +21 -0
- package/dist/index.js +51 -0
- package/dist/server.js +28 -0
- package/dist/socket.js +80 -21
- package/dist/sync/sync-engine.js +213 -0
- package/dist/sync/sync-manager.js +62 -0
- package/dist/sync/sync-manager.test.js +80 -0
- package/dist/utils/package-manager.js +37 -0
- package/dist/utils/updater.js +89 -0
- package/dist/utils/version-checker.js +84 -0
- package/package.json +11 -3
package/dist/config.js
CHANGED
|
@@ -8,6 +8,7 @@ export const ConfigSchema = z.object({
|
|
|
8
8
|
dir: z.string().default(process.cwd()),
|
|
9
9
|
port: z.number().default(2468),
|
|
10
10
|
url: z.string().optional(), // Cloud API URL
|
|
11
|
+
mode: z.enum(['full-local', 'hybrid']).default('full-local'),
|
|
11
12
|
});
|
|
12
13
|
const explorer = cosmiconfig('playcraft', {
|
|
13
14
|
searchPlaces: ['playcraft.config.json', 'playcraft.agent.config.json'],
|
|
@@ -24,6 +25,7 @@ export async function loadConfig(cliOptions) {
|
|
|
24
25
|
projectId: cliOptions.projectId || process.env.PLAYCRAFT_PROJECT_ID || agentConfig.projectId,
|
|
25
26
|
token: cliOptions.token || process.env.PLAYCRAFT_TOKEN || agentConfig.token,
|
|
26
27
|
port: cliOptions.port || (process.env.PLAYCRAFT_PORT ? parseInt(process.env.PLAYCRAFT_PORT) : undefined) || agentConfig.port,
|
|
28
|
+
mode: cliOptions.mode || process.env.PLAYCRAFT_MODE || agentConfig.mode,
|
|
27
29
|
});
|
|
28
30
|
// Resolve absolute path for dir
|
|
29
31
|
config.dir = path.resolve(process.cwd(), config.dir);
|
package/dist/fs-handler.js
CHANGED
|
@@ -80,4 +80,25 @@ export class FSHandler {
|
|
|
80
80
|
throw error;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
+
async writeFile(relativePath, content) {
|
|
84
|
+
if (!this.isSafePath(relativePath)) {
|
|
85
|
+
throw new Error('Access denied: path is outside of the project directory');
|
|
86
|
+
}
|
|
87
|
+
const fullPath = path.join(this.config.dir, relativePath);
|
|
88
|
+
const dir = path.dirname(fullPath);
|
|
89
|
+
await fs.mkdir(dir, { recursive: true });
|
|
90
|
+
if (typeof content === 'string') {
|
|
91
|
+
await fs.writeFile(fullPath, content, 'utf-8');
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
await fs.writeFile(fullPath, content);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async deleteFile(relativePath) {
|
|
98
|
+
if (!this.isSafePath(relativePath)) {
|
|
99
|
+
throw new Error('Access denied: path is outside of the project directory');
|
|
100
|
+
}
|
|
101
|
+
const fullPath = path.join(this.config.dir, relativePath);
|
|
102
|
+
await fs.unlink(fullPath);
|
|
103
|
+
}
|
|
83
104
|
}
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,10 @@ import { statusCommand } from './commands/status.js';
|
|
|
10
10
|
import { logsCommand } from './commands/logs.js';
|
|
11
11
|
import { configCommand } from './commands/config.js';
|
|
12
12
|
import { buildCommand } from './commands/build.js';
|
|
13
|
+
import { upgradeCommand } from './commands/upgrade.js';
|
|
14
|
+
import { checkForUpdates } from './utils/version-checker.js';
|
|
15
|
+
import { syncCommand } from './commands/sync.js';
|
|
16
|
+
import { fixIdsCommand } from './commands/fix-ids.js';
|
|
13
17
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
18
|
const __dirname = dirname(__filename);
|
|
15
19
|
const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
@@ -18,6 +22,11 @@ program
|
|
|
18
22
|
.name('playcraft')
|
|
19
23
|
.description('PlayCraft Local Dev Agent - 本地开发助手')
|
|
20
24
|
.version(packageJson.version);
|
|
25
|
+
// 版本检查(非阻塞,后台执行)
|
|
26
|
+
// 只在非 upgrade 命令时检查,避免重复提示
|
|
27
|
+
if (!process.argv.includes('upgrade')) {
|
|
28
|
+
checkForUpdates(packageJson);
|
|
29
|
+
}
|
|
21
30
|
// init 命令
|
|
22
31
|
program
|
|
23
32
|
.command('init')
|
|
@@ -34,6 +43,7 @@ program
|
|
|
34
43
|
.option('-t, --token <token>', '认证令牌')
|
|
35
44
|
.option('-d, --dir <path>', '监听目录', process.cwd())
|
|
36
45
|
.option('--port <port>', '服务端口', '2468')
|
|
46
|
+
.option('--mode <mode>', '运行模式: full-local(默认) | hybrid', 'full-local')
|
|
37
47
|
.option('--daemon', '守护进程模式(后台运行)')
|
|
38
48
|
.action(async (options) => {
|
|
39
49
|
// 检查是否是内部调用(守护进程)
|
|
@@ -82,6 +92,33 @@ program
|
|
|
82
92
|
.action(async (action, options) => {
|
|
83
93
|
await configCommand(action, options);
|
|
84
94
|
});
|
|
95
|
+
// sync 命令(hybrid 模式下本地/云端双向同步)
|
|
96
|
+
program
|
|
97
|
+
.command('sync')
|
|
98
|
+
.description('本地/云端数据同步(hybrid 模式)')
|
|
99
|
+
.argument('<action>', 'push | pull | status')
|
|
100
|
+
.option('--force', '强制同步')
|
|
101
|
+
.action(async (action, options) => {
|
|
102
|
+
if (action !== 'push' && action !== 'pull' && action !== 'status') {
|
|
103
|
+
console.error('sync action must be push, pull or status');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
await syncCommand(action, options);
|
|
107
|
+
});
|
|
108
|
+
// fix-ids 命令:修复已导入项目的 ID 映射
|
|
109
|
+
program
|
|
110
|
+
.command('fix-ids')
|
|
111
|
+
.description('修复已导入项目的 ID 映射(PlayCanvas ID → PlayCraft numericId)')
|
|
112
|
+
.argument('<project-id>', '项目 ID(numericId 或 UUID)')
|
|
113
|
+
.option('--api-url <url>', '后端 API 地址', process.env.BACKEND_API_URL || 'http://localhost:3001')
|
|
114
|
+
.option('--token <token>', '认证令牌(可选)')
|
|
115
|
+
.action(async (projectId, options) => {
|
|
116
|
+
await fixIdsCommand({
|
|
117
|
+
projectId,
|
|
118
|
+
apiUrl: options.apiUrl,
|
|
119
|
+
token: options.token,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
85
122
|
// build 命令 - 完整流程(阶段1 + 阶段2)
|
|
86
123
|
program
|
|
87
124
|
.command('build')
|
|
@@ -92,6 +129,8 @@ program
|
|
|
92
129
|
.option('-o, --output <path>', '输出目录', './dist')
|
|
93
130
|
.option('-c, --config <file>', '配置文件路径')
|
|
94
131
|
.option('-s, --scenes <scenes>', '选择场景(逗号分隔),例如: MainMenu,Gameplay')
|
|
132
|
+
.option('--clean', '构建前清理旧的输出目录(默认启用)', true)
|
|
133
|
+
.option('--no-clean', '跳过清理旧的输出目录')
|
|
95
134
|
.option('--compress', '压缩引擎代码')
|
|
96
135
|
.option('--analyze', '生成打包分析报告')
|
|
97
136
|
.option('--replace-ammo', '检测到 Ammo 时自动替换为 p2 或 cannon')
|
|
@@ -157,6 +196,7 @@ program
|
|
|
157
196
|
.option('--compress-models', '压缩 3D 模型(默认根据平台配置)')
|
|
158
197
|
.option('--no-compress-models', '禁用模型压缩')
|
|
159
198
|
.option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
|
|
199
|
+
.option('--esm-mode <mode>', 'ESM 模块处理模式 (auto|enabled|disabled)', 'auto')
|
|
160
200
|
.action(async (projectPath, options) => {
|
|
161
201
|
await buildCommand(projectPath, {
|
|
162
202
|
...options,
|
|
@@ -172,6 +212,8 @@ program
|
|
|
172
212
|
.option('-f, --format <format>', '输出格式 (html|zip)', 'html')
|
|
173
213
|
.option('-o, --output <path>', '输出目录', './dist')
|
|
174
214
|
.option('-c, --config <file>', '配置文件路径')
|
|
215
|
+
.option('--clean', '构建前清理旧的输出目录(默认不清理,使用覆盖模式)')
|
|
216
|
+
.option('--no-clean', '使用覆盖模式(默认行为)')
|
|
175
217
|
.option('--compress', '压缩引擎代码')
|
|
176
218
|
.option('--analyze', '生成打包分析报告')
|
|
177
219
|
.option('--replace-ammo', '检测到 Ammo 时自动替换为 p2 或 cannon')
|
|
@@ -190,6 +232,7 @@ program
|
|
|
190
232
|
.option('--compress-models', '压缩 3D 模型(默认根据平台配置)')
|
|
191
233
|
.option('--no-compress-models', '禁用模型压缩')
|
|
192
234
|
.option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
|
|
235
|
+
.option('--esm-mode <mode>', 'ESM 模块处理模式 (auto|enabled|disabled)', 'auto')
|
|
193
236
|
.action(async (baseBuildDir, options) => {
|
|
194
237
|
await buildCommand(baseBuildDir, {
|
|
195
238
|
...options,
|
|
@@ -205,4 +248,12 @@ program
|
|
|
205
248
|
const { inspectCommand } = await import('./commands/inspect.js');
|
|
206
249
|
await inspectCommand(projectPath);
|
|
207
250
|
});
|
|
251
|
+
// upgrade 命令 - 检查并更新 CLI
|
|
252
|
+
program
|
|
253
|
+
.command('upgrade')
|
|
254
|
+
.description('检查并更新 CLI 到最新版本')
|
|
255
|
+
.option('--check-only', '仅检查版本,不更新')
|
|
256
|
+
.action(async (options) => {
|
|
257
|
+
await upgradeCommand(options);
|
|
258
|
+
});
|
|
208
259
|
program.parse(process.argv);
|
package/dist/server.js
CHANGED
|
@@ -73,6 +73,34 @@ export function createServer(config, fsHandler) {
|
|
|
73
73
|
res.status(500).json({ error: error.message });
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
|
+
// Write file
|
|
77
|
+
app.post('/file', async (req, res) => {
|
|
78
|
+
const { path: filePath, content } = req.body ?? {};
|
|
79
|
+
if (!filePath) {
|
|
80
|
+
return res.status(400).json({ error: 'Missing path parameter' });
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await fsHandler.writeFile(filePath, content ?? '');
|
|
84
|
+
res.json({ success: true, path: filePath });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
res.status(500).json({ error: error.message });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
// Delete file
|
|
91
|
+
app.delete('/file', async (req, res) => {
|
|
92
|
+
const filePath = req.query.path;
|
|
93
|
+
if (!filePath) {
|
|
94
|
+
return res.status(400).json({ error: 'Missing path parameter' });
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await fsHandler.deleteFile(filePath);
|
|
98
|
+
res.json({ success: true });
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
res.status(500).json({ error: error.message });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
76
104
|
// Batch get files
|
|
77
105
|
app.post('/files', async (req, res) => {
|
|
78
106
|
const { paths } = req.body;
|
package/dist/socket.js
CHANGED
|
@@ -1,21 +1,73 @@
|
|
|
1
1
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
|
+
/** Match file path against glob pattern. Supports * (segment) and ** (any path). */
|
|
3
|
+
function matchPattern(filePath, pattern) {
|
|
4
|
+
const pathNorm = filePath.replace(/\\/g, '/');
|
|
5
|
+
const parts = pathNorm.split('/').filter(Boolean);
|
|
6
|
+
const patternParts = pattern.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
7
|
+
let pi = 0;
|
|
8
|
+
let fi = 0;
|
|
9
|
+
while (pi < patternParts.length && fi < parts.length) {
|
|
10
|
+
const p = patternParts[pi];
|
|
11
|
+
if (p === '**') {
|
|
12
|
+
pi++;
|
|
13
|
+
if (pi === patternParts.length)
|
|
14
|
+
return true;
|
|
15
|
+
while (fi < parts.length) {
|
|
16
|
+
if (matchRest(parts, patternParts, fi, pi))
|
|
17
|
+
return true;
|
|
18
|
+
fi++;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
if (p !== '*' && p !== parts[fi])
|
|
23
|
+
return false;
|
|
24
|
+
pi++;
|
|
25
|
+
fi++;
|
|
26
|
+
}
|
|
27
|
+
if (pi < patternParts.length && patternParts[pi] === '**')
|
|
28
|
+
pi++;
|
|
29
|
+
return pi === patternParts.length && fi === parts.length;
|
|
30
|
+
}
|
|
31
|
+
function matchRest(parts, patternParts, fi, pi) {
|
|
32
|
+
while (pi < patternParts.length && fi < parts.length) {
|
|
33
|
+
const p = patternParts[pi];
|
|
34
|
+
if (p === '**') {
|
|
35
|
+
pi++;
|
|
36
|
+
if (pi === patternParts.length)
|
|
37
|
+
return true;
|
|
38
|
+
while (fi < parts.length) {
|
|
39
|
+
if (matchRest(parts, patternParts, fi, pi))
|
|
40
|
+
return true;
|
|
41
|
+
fi++;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (p !== '*' && p !== parts[fi])
|
|
46
|
+
return false;
|
|
47
|
+
pi++;
|
|
48
|
+
fi++;
|
|
49
|
+
}
|
|
50
|
+
if (pi < patternParts.length && patternParts[pi] === '**')
|
|
51
|
+
pi++;
|
|
52
|
+
return pi === patternParts.length && fi === parts.length;
|
|
53
|
+
}
|
|
2
54
|
export class SocketServer {
|
|
3
55
|
config;
|
|
4
56
|
wss;
|
|
5
|
-
clients = new
|
|
57
|
+
clients = new Map();
|
|
6
58
|
pingInterval = null;
|
|
7
59
|
onConnectionChange;
|
|
8
60
|
constructor(server, config, onConnectionChange) {
|
|
9
61
|
this.config = config;
|
|
10
|
-
|
|
62
|
+
// 注意:必须显式指定 path='/',避免拦截 /realtime、/messenger 等其他 WebSocket 服务
|
|
63
|
+
this.wss = new WebSocketServer({ server, path: '/' });
|
|
11
64
|
this.onConnectionChange = onConnectionChange;
|
|
12
65
|
this.wss.on('connection', (ws) => {
|
|
13
|
-
this.clients.
|
|
66
|
+
this.clients.set(ws, { patterns: ['**/*'] });
|
|
14
67
|
// Notify connection change
|
|
15
68
|
if (this.onConnectionChange) {
|
|
16
69
|
this.onConnectionChange(this.clients.size);
|
|
17
70
|
}
|
|
18
|
-
// Handle incoming messages
|
|
19
71
|
ws.on('message', (data) => {
|
|
20
72
|
try {
|
|
21
73
|
const message = JSON.parse(data.toString());
|
|
@@ -27,12 +79,10 @@ export class SocketServer {
|
|
|
27
79
|
});
|
|
28
80
|
ws.on('close', () => {
|
|
29
81
|
this.clients.delete(ws);
|
|
30
|
-
// Notify connection change
|
|
31
82
|
if (this.onConnectionChange) {
|
|
32
83
|
this.onConnectionChange(this.clients.size);
|
|
33
84
|
}
|
|
34
85
|
});
|
|
35
|
-
// Send initial hello
|
|
36
86
|
ws.send(JSON.stringify({
|
|
37
87
|
type: 'hello',
|
|
38
88
|
payload: {
|
|
@@ -40,10 +90,8 @@ export class SocketServer {
|
|
|
40
90
|
version: '1.0.0'
|
|
41
91
|
}
|
|
42
92
|
}));
|
|
43
|
-
// Send connection status
|
|
44
93
|
this.broadcast('connection_status', { connected: true });
|
|
45
94
|
});
|
|
46
|
-
// Start ping interval
|
|
47
95
|
this.startPingInterval();
|
|
48
96
|
}
|
|
49
97
|
handleMessage(ws, message) {
|
|
@@ -51,14 +99,18 @@ export class SocketServer {
|
|
|
51
99
|
case 'ping':
|
|
52
100
|
ws.send(JSON.stringify({ type: 'pong', payload: message.payload }));
|
|
53
101
|
break;
|
|
54
|
-
case 'subscribe':
|
|
55
|
-
|
|
56
|
-
|
|
102
|
+
case 'subscribe': {
|
|
103
|
+
const patterns = message.payload?.patterns || ['**/*'];
|
|
104
|
+
const info = this.clients.get(ws);
|
|
105
|
+
if (info) {
|
|
106
|
+
info.patterns = Array.isArray(patterns) ? patterns : [patterns];
|
|
107
|
+
}
|
|
57
108
|
ws.send(JSON.stringify({
|
|
58
109
|
type: 'subscribed',
|
|
59
|
-
payload: { patterns:
|
|
110
|
+
payload: { patterns: info?.patterns ?? patterns }
|
|
60
111
|
}));
|
|
61
112
|
break;
|
|
113
|
+
}
|
|
62
114
|
}
|
|
63
115
|
}
|
|
64
116
|
startPingInterval() {
|
|
@@ -66,7 +118,7 @@ export class SocketServer {
|
|
|
66
118
|
clearInterval(this.pingInterval);
|
|
67
119
|
}
|
|
68
120
|
this.pingInterval = setInterval(() => {
|
|
69
|
-
for (const client of this.clients) {
|
|
121
|
+
for (const client of this.clients.keys()) {
|
|
70
122
|
if (client.readyState === WebSocket.OPEN) {
|
|
71
123
|
try {
|
|
72
124
|
client.send(JSON.stringify({ type: 'ping', payload: Date.now() }));
|
|
@@ -83,8 +135,7 @@ export class SocketServer {
|
|
|
83
135
|
clearInterval(this.pingInterval);
|
|
84
136
|
this.pingInterval = null;
|
|
85
137
|
}
|
|
86
|
-
|
|
87
|
-
for (const client of this.clients) {
|
|
138
|
+
for (const client of this.clients.keys()) {
|
|
88
139
|
try {
|
|
89
140
|
client.close();
|
|
90
141
|
}
|
|
@@ -93,23 +144,31 @@ export class SocketServer {
|
|
|
93
144
|
}
|
|
94
145
|
}
|
|
95
146
|
this.clients.clear();
|
|
96
|
-
// Close WebSocket server
|
|
97
147
|
this.wss.close();
|
|
98
148
|
}
|
|
99
149
|
broadcast(type, payload) {
|
|
100
150
|
const message = JSON.stringify({ type, payload });
|
|
101
|
-
for (const client of this.clients) {
|
|
151
|
+
for (const client of this.clients.keys()) {
|
|
102
152
|
if (client.readyState === WebSocket.OPEN) {
|
|
103
153
|
client.send(message);
|
|
104
154
|
}
|
|
105
155
|
}
|
|
106
156
|
}
|
|
107
|
-
notifyFileChange(
|
|
108
|
-
|
|
109
|
-
path,
|
|
157
|
+
notifyFileChange(filePath, changeType) {
|
|
158
|
+
const payload = {
|
|
159
|
+
path: filePath,
|
|
110
160
|
changeType,
|
|
111
161
|
timestamp: new Date().toISOString()
|
|
112
|
-
}
|
|
162
|
+
};
|
|
163
|
+
const message = JSON.stringify({ type: 'file_changed', payload });
|
|
164
|
+
for (const [ws, info] of this.clients) {
|
|
165
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
166
|
+
continue;
|
|
167
|
+
const matches = info.patterns.some((pattern) => matchPattern(filePath, pattern));
|
|
168
|
+
if (matches) {
|
|
169
|
+
ws.send(message);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
113
172
|
}
|
|
114
173
|
getConnectionCount() {
|
|
115
174
|
return this.clients.size;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncEngine – CLI entry point for sync push/pull/status.
|
|
3
|
+
* Uses CloudConnectionManager and SyncManager to perform sync operations.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import pc from 'picocolors';
|
|
8
|
+
import ora from 'ora';
|
|
9
|
+
import { loadConfig } from '../config.js';
|
|
10
|
+
import { CloudConnectionManager } from '../agent/cloud-connection.js';
|
|
11
|
+
import { SyncManager } from './sync-manager.js';
|
|
12
|
+
const IGNORE_PATTERNS = [
|
|
13
|
+
'node_modules',
|
|
14
|
+
'.git',
|
|
15
|
+
'dist',
|
|
16
|
+
'playcraft.agent.config.json',
|
|
17
|
+
'.DS_Store',
|
|
18
|
+
];
|
|
19
|
+
function shouldSyncFile(relativePath) {
|
|
20
|
+
const normalized = relativePath.replace(/\\/g, '/');
|
|
21
|
+
if (IGNORE_PATTERNS.some((p) => normalized.includes(p)))
|
|
22
|
+
return false;
|
|
23
|
+
if (normalized.startsWith('.'))
|
|
24
|
+
return false;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
async function* walkFiles(dir, baseDir) {
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const ent of entries) {
|
|
36
|
+
const full = path.join(dir, ent.name);
|
|
37
|
+
const rel = path.relative(baseDir, full);
|
|
38
|
+
if (!shouldSyncFile(rel))
|
|
39
|
+
continue;
|
|
40
|
+
if (ent.isFile()) {
|
|
41
|
+
yield rel;
|
|
42
|
+
}
|
|
43
|
+
else if (ent.isDirectory()) {
|
|
44
|
+
yield* walkFiles(full, baseDir);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export class SyncEngine {
|
|
49
|
+
options;
|
|
50
|
+
cloudConnection = null;
|
|
51
|
+
syncManager = null;
|
|
52
|
+
constructor(options) {
|
|
53
|
+
this.options = options;
|
|
54
|
+
}
|
|
55
|
+
async ensureConnected() {
|
|
56
|
+
if (this.cloudConnection?.isConnected && this.syncManager) {
|
|
57
|
+
return { connection: this.cloudConnection, syncManager: this.syncManager };
|
|
58
|
+
}
|
|
59
|
+
const connection = new CloudConnectionManager({
|
|
60
|
+
url: this.options.url,
|
|
61
|
+
token: this.options.token,
|
|
62
|
+
projectId: this.options.projectId,
|
|
63
|
+
});
|
|
64
|
+
await connection.connect();
|
|
65
|
+
const syncManager = new SyncManager({
|
|
66
|
+
cloudConnection: connection,
|
|
67
|
+
projectDir: this.options.projectDir,
|
|
68
|
+
});
|
|
69
|
+
this.cloudConnection = connection;
|
|
70
|
+
this.syncManager = syncManager;
|
|
71
|
+
return { connection, syncManager };
|
|
72
|
+
}
|
|
73
|
+
async push(options) {
|
|
74
|
+
const spinner = ora('连接云端...').start();
|
|
75
|
+
try {
|
|
76
|
+
const { syncManager } = await this.ensureConnected();
|
|
77
|
+
spinner.succeed('已连接');
|
|
78
|
+
const files = [];
|
|
79
|
+
for await (const rel of walkFiles(this.options.projectDir, this.options.projectDir)) {
|
|
80
|
+
files.push(rel);
|
|
81
|
+
}
|
|
82
|
+
const toUpload = files.filter((f) => f.endsWith('.json') || f.includes('manifest') || f.startsWith('scenes/') || f.startsWith('assets/'));
|
|
83
|
+
if (toUpload.length === 0) {
|
|
84
|
+
toUpload.push(...files.slice(0, 50));
|
|
85
|
+
}
|
|
86
|
+
if (toUpload.length === 0) {
|
|
87
|
+
console.log(pc.yellow('没有需要同步的文件'));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
console.log(pc.cyan(`\n准备推送 ${toUpload.length} 个文件\n`));
|
|
91
|
+
let ok = 0;
|
|
92
|
+
let fail = 0;
|
|
93
|
+
for (const file of toUpload) {
|
|
94
|
+
const result = await syncManager.upload(file);
|
|
95
|
+
if (result.success) {
|
|
96
|
+
ok++;
|
|
97
|
+
console.log(pc.green(` ↑ ${file}`));
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
fail++;
|
|
101
|
+
console.log(pc.red(` ✗ ${file}: ${result.error}`));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
console.log(pc.cyan(`\n完成: ${ok} 成功, ${fail} 失败`));
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
spinner.fail('连接失败');
|
|
108
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
109
|
+
console.error(pc.red(msg));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await this.cloudConnection?.disconnect();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async pull(options) {
|
|
117
|
+
const spinner = ora('连接云端...').start();
|
|
118
|
+
try {
|
|
119
|
+
const { syncManager } = await this.ensureConnected();
|
|
120
|
+
spinner.succeed('已连接');
|
|
121
|
+
const pending = syncManager.getPendingDownloads();
|
|
122
|
+
if (pending.length > 0) {
|
|
123
|
+
console.log(pc.cyan(`\n准备拉取 ${pending.length} 个文件\n`));
|
|
124
|
+
let ok = 0;
|
|
125
|
+
let fail = 0;
|
|
126
|
+
for (const file of pending) {
|
|
127
|
+
const result = await syncManager.download(file);
|
|
128
|
+
if (result.success) {
|
|
129
|
+
ok++;
|
|
130
|
+
console.log(pc.green(` ↓ ${file}`));
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
fail++;
|
|
134
|
+
console.log(pc.red(` ✗ ${file}: ${result.error}`));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
console.log(pc.cyan(`\n完成: ${ok} 成功, ${fail} 失败`));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const state = await this.cloudConnection.request('GET', '/api/sync/state');
|
|
142
|
+
const paths = state?.files?.map((f) => f.path) ?? [];
|
|
143
|
+
if (paths.length === 0) {
|
|
144
|
+
console.log(pc.yellow('云端暂无文件列表,或 API 未实现'));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
console.log(pc.cyan(`\n准备拉取 ${paths.length} 个文件\n`));
|
|
148
|
+
let ok = 0;
|
|
149
|
+
let fail = 0;
|
|
150
|
+
for (const file of paths) {
|
|
151
|
+
const result = await syncManager.download(file);
|
|
152
|
+
if (result.success) {
|
|
153
|
+
ok++;
|
|
154
|
+
console.log(pc.green(` ↓ ${file}`));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
fail++;
|
|
158
|
+
console.log(pc.red(` ✗ ${file}: ${result.error}`));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
console.log(pc.cyan(`\n完成: ${ok} 成功, ${fail} 失败`));
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
console.log(pc.yellow('拉取需要云端 /api/sync/state 支持'));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
spinner.fail('连接失败');
|
|
169
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
170
|
+
console.error(pc.red(msg));
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
await this.cloudConnection?.disconnect();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async status() {
|
|
178
|
+
const spinner = ora('连接云端...').start();
|
|
179
|
+
try {
|
|
180
|
+
const { connection, syncManager } = await this.ensureConnected();
|
|
181
|
+
spinner.succeed('已连接');
|
|
182
|
+
const state = connection.connectionState;
|
|
183
|
+
const pendingUp = syncManager.getPendingUploads();
|
|
184
|
+
const pendingDown = syncManager.getPendingDownloads();
|
|
185
|
+
console.log(pc.cyan('\n同步状态\n'));
|
|
186
|
+
console.log(` 连接: ${state === 'connected' ? pc.green(state) : pc.yellow(state)}`);
|
|
187
|
+
console.log(` 待上传: ${pendingUp.length}`);
|
|
188
|
+
console.log(` 待下载: ${pendingDown.length}`);
|
|
189
|
+
console.log();
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
spinner.fail('连接失败');
|
|
193
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
194
|
+
console.error(pc.red(msg));
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await this.cloudConnection?.disconnect();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
export async function createSyncEngineFromConfig() {
|
|
203
|
+
const config = await loadConfig({});
|
|
204
|
+
if (!config.url || !config.token || !config.projectId) {
|
|
205
|
+
throw new Error('Sync 需要配置 url、token 和 projectId(hybrid 模式)');
|
|
206
|
+
}
|
|
207
|
+
return new SyncEngine({
|
|
208
|
+
url: config.url,
|
|
209
|
+
token: config.token,
|
|
210
|
+
projectId: config.projectId,
|
|
211
|
+
projectDir: config.dir,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncManager – manages local/cloud file sync for Hybrid mode.
|
|
3
|
+
* MVP: upload/download stubs; full implementation in Week 3-4.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
export class SyncManager {
|
|
8
|
+
options;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
12
|
+
async upload(filePath) {
|
|
13
|
+
const fullPath = path.join(this.options.projectDir, filePath);
|
|
14
|
+
try {
|
|
15
|
+
const content = await fs.readFile(fullPath);
|
|
16
|
+
const base64 = content.toString('base64');
|
|
17
|
+
await this.options.cloudConnection.request('POST', '/api/sync/upload', {
|
|
18
|
+
path: filePath,
|
|
19
|
+
data: base64,
|
|
20
|
+
compressed: false,
|
|
21
|
+
});
|
|
22
|
+
return { success: true, path: filePath, direction: 'up', timestamp: new Date() };
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
26
|
+
return {
|
|
27
|
+
success: false,
|
|
28
|
+
path: filePath,
|
|
29
|
+
direction: 'up',
|
|
30
|
+
timestamp: new Date(),
|
|
31
|
+
error: message,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async download(filePath) {
|
|
36
|
+
try {
|
|
37
|
+
const content = await this.options.cloudConnection.request('GET', `/api/sync/download?path=${encodeURIComponent(filePath)}`);
|
|
38
|
+
const base64 = typeof content === 'object' && content && 'data' in content ? content.data : String(content);
|
|
39
|
+
const fullPath = path.join(this.options.projectDir, filePath);
|
|
40
|
+
const dir = path.dirname(fullPath);
|
|
41
|
+
await fs.mkdir(dir, { recursive: true });
|
|
42
|
+
await fs.writeFile(fullPath, Buffer.from(base64, 'base64'));
|
|
43
|
+
return { success: true, path: filePath, direction: 'down', timestamp: new Date() };
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
47
|
+
return {
|
|
48
|
+
success: false,
|
|
49
|
+
path: filePath,
|
|
50
|
+
direction: 'down',
|
|
51
|
+
timestamp: new Date(),
|
|
52
|
+
error: message,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
getPendingUploads() {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
getPendingDownloads() {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|