@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/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);
@@ -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 Set();
57
+ clients = new Map();
6
58
  pingInterval = null;
7
59
  onConnectionChange;
8
60
  constructor(server, config, onConnectionChange) {
9
61
  this.config = config;
10
- this.wss = new WebSocketServer({ server });
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.add(ws);
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
- // Store subscription patterns for this client (simplified implementation)
56
- // In a full implementation, we would filter file change notifications
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: message.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
- // Close all WebSocket connections
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(path, changeType) {
108
- this.broadcast('file_changed', {
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
+ }