sillyspec 3.11.11 → 3.12.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.
package/src/sync.js ADDED
@@ -0,0 +1,497 @@
1
+ /**
2
+ * SillySpec SyncManager — SillyHub 平台同步模块
3
+ *
4
+ * 独立于 ProgressManager,由 run.js 和 index.js 调用。
5
+ * Best effort:所有网络失败 console.warn,不抛错,不阻塞主流程。
6
+ *
7
+ * 配置来源:.sillyspec/local.yaml 中的 platform 段
8
+ * HTTP 请求:Node.js 原生 fetch(Node 22+)
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
12
+ import { join } from 'path';
13
+
14
+ const LOCAL_YAML = '.sillyspec/local.yaml';
15
+ const CHANGES_DIR = '.sillyspec/changes';
16
+ const REQUEST_TIMEOUT_MS = 10_000;
17
+
18
+ /** 四件套文档文件名 */
19
+ const DOCUMENT_FILES = ['proposal.md', 'design.md', 'requirements.md', 'tasks.md'];
20
+
21
+ // ── YAML 辅助 ──
22
+
23
+ /**
24
+ * 简易 YAML 读写,只处理 project 段的扁平结构。
25
+ * 与 worktree-guard.js 的 parseSimpleYaml 保持一致的轻量风格。
26
+ */
27
+ function readLocalYaml(cwd) {
28
+ const p = join(cwd, LOCAL_YAML);
29
+ if (!existsSync(p)) return {};
30
+ try {
31
+ return parseSimpleYaml(readFileSync(p, 'utf8'));
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ function writeLocalYaml(cwd, obj) {
38
+ const dir = join(cwd, '.sillyspec');
39
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
40
+ const lines = [];
41
+ const rootKeys = Object.keys(obj);
42
+ for (const key of rootKeys) {
43
+ const val = obj[key];
44
+ if (val === null || val === undefined) continue;
45
+ if (typeof val === 'object' && !Array.isArray(val)) {
46
+ lines.push(`${key}:`);
47
+ for (const [k, v] of Object.entries(val)) {
48
+ if (typeof v === 'string') {
49
+ lines.push(` ${k}: ${v}`);
50
+ } else {
51
+ lines.push(` ${k}: ${JSON.stringify(v)}`);
52
+ }
53
+ }
54
+ } else {
55
+ lines.push(`${key}: ${typeof val === 'string' ? val : JSON.stringify(val)}`);
56
+ }
57
+ }
58
+ writeFileSync(join(cwd, LOCAL_YAML), lines.join('\n') + '\n', 'utf8');
59
+ }
60
+
61
+ function parseSimpleYaml(content) {
62
+ const result = {};
63
+ let currentSection = null;
64
+ for (const line of content.split('\n')) {
65
+ const trimmed = line.trim();
66
+ if (!trimmed || trimmed.startsWith('#')) continue;
67
+ if (!trimmed.startsWith(' ')) {
68
+ const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
69
+ if (m) {
70
+ const key = m[1];
71
+ const val = m[2].trim();
72
+ if (val) {
73
+ result[key] = val;
74
+ currentSection = null;
75
+ } else {
76
+ result[key] = {};
77
+ currentSection = key;
78
+ }
79
+ }
80
+ } else if (currentSection) {
81
+ const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
82
+ if (m && result[currentSection] && typeof result[currentSection] === 'object') {
83
+ result[currentSection][m[1]] = m[2].trim();
84
+ }
85
+ }
86
+ }
87
+ return result;
88
+ }
89
+
90
+ // ── HTTP 辅助 ──
91
+
92
+ async function fetchJson(url, options = {}) {
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
95
+ try {
96
+ const res = await fetch(url, { ...options, signal: controller.signal });
97
+ if (!res.ok) {
98
+ const text = await res.text().catch(() => '');
99
+ console.warn(`[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`);
100
+ return null;
101
+ }
102
+ const ct = res.headers.get('content-type') || '';
103
+ if (ct.includes('application/json')) {
104
+ return res.json();
105
+ }
106
+ return null;
107
+ } catch (err) {
108
+ if (err.name === 'AbortError') {
109
+ console.warn(`[sync] ${url} 请求超时 (${REQUEST_TIMEOUT_MS}ms)`);
110
+ } else {
111
+ console.warn(`[sync] ${url} 请求失败: ${err.message}`);
112
+ }
113
+ return null;
114
+ } finally {
115
+ clearTimeout(timer);
116
+ }
117
+ }
118
+
119
+ // ── SyncManager ──
120
+
121
+ export class SyncManager {
122
+ constructor(cwd) {
123
+ this.cwd = cwd;
124
+ }
125
+
126
+ /**
127
+ * 连接 SillyHub 平台。
128
+ * 保存配置到 .sillyspec/local.yaml,发送 ping 验证连接。
129
+ */
130
+ async connect(url, token) {
131
+ // 验证连接
132
+ const healthUrl = `${url.replace(/\/+$/, '')}/api/health`;
133
+ const result = await fetchJson(healthUrl);
134
+ if (result === null) {
135
+ console.warn(`[sync] 平台连接验证失败: ${url}`);
136
+ return;
137
+ }
138
+ console.log(`[sync] 平台连接成功: ${url}`);
139
+
140
+ // 写入 local.yaml
141
+ const config = readLocalYaml(this.cwd);
142
+ config.platform = {
143
+ url: url.replace(/\/+$/, ''),
144
+ token,
145
+ last_connected: new Date().toISOString(),
146
+ };
147
+ writeLocalYaml(this.cwd, config);
148
+ }
149
+
150
+ /**
151
+ * 断开平台连接。
152
+ * 从 local.yaml 删除 platform 配置段。
153
+ */
154
+ disconnect() {
155
+ const p = join(this.cwd, LOCAL_YAML);
156
+ if (!existsSync(p)) {
157
+ console.log('[sync] 已断开连接(无配置文件)');
158
+ return;
159
+ }
160
+ const config = readLocalYaml(this.cwd);
161
+ if (!config.platform) {
162
+ console.log('[sync] 已断开连接(未连接)');
163
+ return;
164
+ }
165
+ delete config.platform;
166
+ if (Object.keys(config).length === 0) {
167
+ // 配置为空,删除整个文件
168
+ try { unlinkSync(p); } catch { /* best effort */ }
169
+ } else {
170
+ writeLocalYaml(this.cwd, config);
171
+ }
172
+ console.log('[sync] 已断开连接');
173
+ }
174
+
175
+ /**
176
+ * 增量同步变更的 progress 状态到平台。
177
+ * 读取 ProgressManager.read() 的数据,POST 到平台。
178
+ * 同步完成后更新 changes 表的 platform_last_sync 字段。
179
+ */
180
+ async sync(changeName) {
181
+ const platform = this._getPlatform();
182
+ if (!platform) {
183
+ console.warn('[sync] 未连接平台,请先 sillyspec platform connect');
184
+ return { synced: 0, errors: ['未连接平台'] };
185
+ }
186
+
187
+ if (!changeName) {
188
+ console.warn('[sync] sync 需要指定变更名称 (changeName)');
189
+ return { synced: 0, errors: ['未指定变更名称'] };
190
+ }
191
+
192
+ // 检查变更是否存在
193
+ const changeDir = join(this.cwd, CHANGES_DIR, changeName);
194
+ if (!existsSync(changeDir)) {
195
+ console.warn(`[sync] 变更不存在: ${changeName}`);
196
+ return { synced: 0, errors: [`变更不存在: ${changeName}`] };
197
+ }
198
+
199
+ // 读取 progress 数据(通过导入 ProgressManager 动态调用)
200
+ let progressData;
201
+ try {
202
+ const { ProgressManager } = await import('./progress.js');
203
+ const pm = new ProgressManager();
204
+ progressData = await pm.read(this.cwd, changeName);
205
+ } catch (err) {
206
+ console.warn(`[sync] 读取 progress 失败 (${changeName}): ${err.message}`);
207
+ return { synced: 0, errors: [`读取 progress 失败: ${err.message}`] };
208
+ }
209
+
210
+ // POST 到平台
211
+ const syncUrl = `${platform.url}/api/changes/${changeName}/progress`;
212
+ const result = await fetchJson(syncUrl, {
213
+ method: 'POST',
214
+ headers: {
215
+ 'Content-Type': 'application/json',
216
+ Authorization: `Bearer ${platform.token}`,
217
+ },
218
+ body: JSON.stringify(progressData),
219
+ });
220
+
221
+ if (!result) {
222
+ return { synced: 0, errors: [`同步请求失败: ${changeName}`] };
223
+ }
224
+
225
+ // 更新 platform_last_sync
226
+ try {
227
+ const { ProgressManager } = await import('./progress.js');
228
+ const pm = new ProgressManager();
229
+ await pm._updatePlatformLastSync(this.cwd, changeName);
230
+ } catch (err) {
231
+ console.warn(`[sync] 更新 platform_last_sync 失败: ${err.message}`);
232
+ }
233
+
234
+ console.log(`[sync] 已同步变更: ${changeName}`);
235
+ return { synced: 1, errors: [] };
236
+ }
237
+
238
+ /**
239
+ * 同步四件套文档到平台(全量同步)。
240
+ * POST {url}/api/changes/{changeName}/documents
241
+ */
242
+ async syncDocuments(changeName) {
243
+ const platform = this._getPlatform();
244
+ if (!platform) {
245
+ console.warn('[sync] 未连接平台,请先 sillyspec platform connect');
246
+ return { synced: 0, errors: ['未连接平台'] };
247
+ }
248
+
249
+ if (!changeName) {
250
+ console.warn('[sync] syncDocuments 需要指定变更名称 (changeName)');
251
+ return { synced: 0, errors: ['未指定变更名称'] };
252
+ }
253
+
254
+ const changeDir = join(this.cwd, CHANGES_DIR, changeName);
255
+ if (!existsSync(changeDir)) {
256
+ console.warn(`[sync] 变更不存在: ${changeName}`);
257
+ return { synced: 0, errors: [`变更不存在: ${changeName}`] };
258
+ }
259
+
260
+ const documents = {};
261
+ let syncedCount = 0;
262
+ const errors = [];
263
+
264
+ for (const docFile of DOCUMENT_FILES) {
265
+ const docPath = join(changeDir, docFile);
266
+ if (existsSync(docPath)) {
267
+ try {
268
+ documents[docFile] = readFileSync(docPath, 'utf8');
269
+ syncedCount++;
270
+ } catch (err) {
271
+ errors.push(`读取 ${docFile} 失败: ${err.message}`);
272
+ }
273
+ }
274
+ }
275
+
276
+ if (syncedCount === 0) {
277
+ console.warn(`[sync] 未找到可同步的文档: ${changeName}`);
278
+ return { synced: 0, errors: [...errors, '无可用文档'] };
279
+ }
280
+
281
+ const docUrl = `${platform.url}/api/changes/${changeName}/documents`;
282
+ const result = await fetchJson(docUrl, {
283
+ method: 'POST',
284
+ headers: {
285
+ 'Content-Type': 'application/json',
286
+ Authorization: `Bearer ${platform.token}`,
287
+ },
288
+ body: JSON.stringify(documents),
289
+ });
290
+
291
+ if (!result) {
292
+ return { synced: 0, errors: [...errors, '文档同步请求失败'] };
293
+ }
294
+
295
+ console.log(`[sync] 已同步 ${syncedCount} 个文档: ${changeName}`);
296
+ return { synced: syncedCount, errors };
297
+ }
298
+
299
+ /**
300
+ * 检查变更的审批状态。
301
+ * GET {url}/api/changes/{changeName}/approval
302
+ * 返回 { status: 'pending'|'approved'|'rejected', reason?: string }
303
+ */
304
+ async checkApproval(changeName) {
305
+ const platform = this._getPlatform();
306
+ if (!platform) {
307
+ console.warn('[sync] 未连接平台,请先 sillyspec platform connect');
308
+ return { status: 'pending', reason: '未连接平台' };
309
+ }
310
+
311
+ if (!changeName) {
312
+ console.warn('[sync] checkApproval 需要指定变更名称 (changeName)');
313
+ return { status: 'pending', reason: '未指定变更名称' };
314
+ }
315
+
316
+ const approvalUrl = `${platform.url}/api/changes/${changeName}/approval`;
317
+ const result = await fetchJson(approvalUrl, {
318
+ headers: { Authorization: `Bearer ${platform.token}` },
319
+ });
320
+
321
+ if (!result) {
322
+ console.warn(`[sync] 检查审批状态失败: ${changeName}`);
323
+ return { status: 'pending', reason: '请求失败' };
324
+ }
325
+
326
+ // 更新本地 approvals 表
327
+ try {
328
+ const { ProgressManager } = await import('./progress.js');
329
+ const pm = new ProgressManager();
330
+ await pm._updateApprovalStatus(this.cwd, changeName, result.status, result.reason);
331
+ } catch (err) {
332
+ console.warn(`[sync] 更新本地审批状态失败: ${err.message}`);
333
+ }
334
+
335
+ if (result.status === 'rejected') {
336
+ console.warn(`[sync] 审批被拒绝 (${changeName}): ${result.reason || '无原因'}`);
337
+ }
338
+
339
+ return result;
340
+ }
341
+
342
+ /**
343
+ * 查看同步状态。
344
+ * 读取 local.yaml 中的 platform 配置,返回连接信息。
345
+ */
346
+ status() {
347
+ const config = readLocalYaml(this.cwd);
348
+ const platform = config.platform;
349
+ if (!platform) {
350
+ return { connected: false };
351
+ }
352
+ return {
353
+ connected: true,
354
+ url: platform.url,
355
+ lastSync: platform.last_connected || null,
356
+ };
357
+ }
358
+
359
+ /** 获取当前平台配置,未连接返回 null */
360
+ _getPlatform() {
361
+ const config = readLocalYaml(this.cwd);
362
+ return config.platform || null;
363
+ }
364
+ }
365
+
366
+ // ── CLI 入口函数 ──
367
+
368
+ /**
369
+ * syncModule — sillyspec platform 子命令入口
370
+ *
371
+ * 用法:
372
+ * sillyspec platform connect <url> <token>
373
+ * sillyspec platform disconnect
374
+ * sillyspec platform sync [changeName]
375
+ * sillyspec platform sync-docs [changeName]
376
+ * sillyspec platform approval <changeName>
377
+ * sillyspec platform status
378
+ *
379
+ * @param {string[]} args — 子命令及参数
380
+ * @param {string} cwd — 工作目录
381
+ */
382
+ /**
383
+ * 便捷函数导出 — 供 index.js 和 run.js 直接调用
384
+ */
385
+ export async function connect(url, token, cwd) {
386
+ return new SyncManager(cwd).connect(url, token);
387
+ }
388
+
389
+ export async function disconnect(cwd) {
390
+ return new SyncManager(cwd).disconnect();
391
+ }
392
+
393
+ export async function sync(changeName, cwd) {
394
+ return new SyncManager(cwd).sync(changeName);
395
+ }
396
+
397
+ export async function syncDocuments(changeName, cwd) {
398
+ return new SyncManager(cwd).syncDocuments(changeName);
399
+ }
400
+
401
+ export async function checkApproval(changeName, cwd) {
402
+ return new SyncManager(cwd).checkApproval(changeName);
403
+ }
404
+
405
+ export async function approve(changeName, cwd) {
406
+ // TODO: SillyHub 平台侧实现后启用
407
+ console.warn(`[sync] approve 尚未实现 (${changeName})`);
408
+ }
409
+
410
+ export async function reject(changeName, reason, cwd) {
411
+ // TODO: SillyHub 平台侧实现后启用
412
+ console.warn(`[sync] reject 尚未实现 (${changeName})`);
413
+ }
414
+
415
+ export async function status(cwd) {
416
+ const sm = new SyncManager(cwd);
417
+ const st = sm.status();
418
+ if (!st.connected) {
419
+ console.log('平台: 未连接');
420
+ } else {
421
+ console.log(`平台: ${st.url}`);
422
+ console.log(`上次连接: ${st.lastSync || '未知'}`);
423
+ }
424
+ }
425
+
426
+ /**
427
+ * syncModule — sillyspec platform 子命令入口
428
+ */
429
+ export async function syncModule(args, cwd) {
430
+ const sm = new SyncManager(cwd);
431
+
432
+ const sub = args[0];
433
+
434
+ switch (sub) {
435
+ case 'connect': {
436
+ const url = args[1];
437
+ const token = args[2];
438
+ if (!url || !token) {
439
+ console.error('用法: sillyspec platform connect <url> <token>');
440
+ process.exit(1);
441
+ }
442
+ await sm.connect(url, token);
443
+ break;
444
+ }
445
+
446
+ case 'disconnect':
447
+ sm.disconnect();
448
+ break;
449
+
450
+ case 'sync': {
451
+ const changeName = args[1];
452
+ const result = await sm.sync(changeName);
453
+ if (result.errors.length > 0) {
454
+ console.log(`同步完成,${result.errors.length} 个错误`);
455
+ }
456
+ break;
457
+ }
458
+
459
+ case 'sync-docs':
460
+ case 'sync-documents': {
461
+ const changeName = args[1];
462
+ const result = await sm.syncDocuments(changeName);
463
+ if (result.errors.length > 0) {
464
+ console.log(`文档同步完成,${result.errors.length} 个错误`);
465
+ }
466
+ break;
467
+ }
468
+
469
+ case 'approval':
470
+ case 'check-approval': {
471
+ const changeName = args[1];
472
+ if (!changeName) {
473
+ console.error('用法: sillyspec platform approval <changeName>');
474
+ process.exit(1);
475
+ }
476
+ const approval = await sm.checkApproval(changeName);
477
+ console.log(`审批状态: ${approval.status}${approval.reason ? ` (${approval.reason})` : ''}`);
478
+ break;
479
+ }
480
+
481
+ case 'status': {
482
+ const st = sm.status();
483
+ if (!st.connected) {
484
+ console.log('平台: 未连接');
485
+ } else {
486
+ console.log(`平台: ${st.url}`);
487
+ console.log(`上次连接: ${st.lastSync || '未知'}`);
488
+ }
489
+ break;
490
+ }
491
+
492
+ default:
493
+ console.error(`未知子命令: ${sub || '(无)'}`);
494
+ console.error('可用命令: connect, disconnect, sync, sync-docs, approval, status');
495
+ process.exit(1);
496
+ }
497
+ }