dsh-project-deck 0.0.0-stage → 0.1.0

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/backend/deck ADDED
@@ -0,0 +1,1550 @@
1
+ #!/usr/bin/env python3
2
+ """deck —— 项目甲板 的项目注册表与微信周期播报。
3
+
4
+ 用法:
5
+ deck list 列出项目
6
+ deck preview 生成播报文本(不发送)
7
+ deck report 立即生成并推送到微信
8
+ deck every <小时> 设置播报间隔(可小数,如 0.5 / 4)
9
+ deck on | off 打开 / 关闭周期播报
10
+ deck status 服务与配置状态
11
+ deck add <id> --root <path> [--name 名称] [--kind code] [--note 说明]
12
+ deck rm <id> 移除项目
13
+ deck scan [--add] 扫描发现新项目;--add 按阈值自动登记
14
+ deck image [--no-send|--on|--off] 生成看板图并推送;--on/--off 开关周期附图
15
+ deck daemon (由 launchd 调用)周期播报守护进程
16
+ """
17
+ import base64
18
+ import fcntl
19
+ import glob
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import re
24
+ import secrets
25
+ import subprocess
26
+ import sys
27
+ import time
28
+ import urllib.parse
29
+ import urllib.request
30
+ from datetime import datetime
31
+
32
+ HOME = os.path.expanduser('~')
33
+ BASE = os.path.expanduser(os.environ.get('DECK_HOME', '~/.project-deck'))
34
+ CONFIG = os.path.join(BASE, 'config.json')
35
+ REGISTRY = os.path.join(BASE, 'registry.json')
36
+ STATE = os.path.join(BASE, 'report-state.json')
37
+ LAST = os.path.join(BASE, 'last-report.txt')
38
+ HISTORY = os.path.join(BASE, 'reports.jsonl')
39
+ CHANNEL_STATE = os.path.join(BASE, 'channel-state.json')
40
+ LOG = os.path.join(BASE, 'logs', 'daemon.log')
41
+ WXSEND = os.environ.get('DECK_WXSEND', os.path.join(HOME, 'tools', 'wxsend', 'wxsend'))
42
+ LOCK = os.path.join(BASE, 'daemon.lock')
43
+
44
+ KINDS = {'code': '代码', 'content': '内容', 'ops': '运维', 'life': '生活', 'research': '研究'}
45
+
46
+
47
+ # ── 基础读写 ────────────────────────────────────────────────
48
+ def load_json(path, default):
49
+ try:
50
+ with open(path) as f:
51
+ return json.load(f)
52
+ except (FileNotFoundError, json.JSONDecodeError):
53
+ return default
54
+
55
+
56
+ def save_json(path, data):
57
+ os.makedirs(os.path.dirname(path), exist_ok=True)
58
+ tmp = path + '.tmp'
59
+ with open(tmp, 'w') as f:
60
+ json.dump(data, f, ensure_ascii=False, indent=2)
61
+ f.write('\n')
62
+ os.replace(tmp, path)
63
+
64
+
65
+ def log(msg):
66
+ os.makedirs(os.path.dirname(LOG), exist_ok=True)
67
+ with open(LOG, 'a') as f:
68
+ f.write(f'[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}\n')
69
+
70
+
71
+ # ── 项目数据采集 ────────────────────────────────────────────
72
+ def state_file(p):
73
+ mem = p.get('memory')
74
+ if not mem:
75
+ return None
76
+ path = os.path.join(HOME, '.ai', 'projects', mem.get('project', p['id']),
77
+ 'memory', 'designs', mem.get('slug', p['id']), 'STATE.md')
78
+ return path if os.path.exists(path) else None
79
+
80
+
81
+ NEXT_RE = re.compile(r'next[_ ]?action\s*[::]\s*\*{0,2}\s*(.+)', re.I)
82
+ STATUS_RE = re.compile(r'^\s*[-*]?\s*(?:status|状态)\s*[::]\s*(.+)', re.I | re.M)
83
+
84
+
85
+ def parse_state(path):
86
+ try:
87
+ text = open(path, encoding='utf-8', errors='replace').read(200000)
88
+ except OSError:
89
+ return None, None
90
+ m = NEXT_RE.search(text)
91
+ nxt = m.group(1).strip().strip('*').strip()[:120] if m else None
92
+ s = STATUS_RE.search(text)
93
+ st = s.group(1).strip()[:60] if s else None
94
+ return nxt, st
95
+
96
+
97
+ def board_stats(root):
98
+ bdir = os.path.join(root, '.dsh-graph')
99
+ if not os.path.isdir(bdir):
100
+ return None
101
+ goals_dir = os.path.join(bdir, 'goals')
102
+ counts = {}
103
+ latest = 0
104
+ if os.path.isdir(goals_dir):
105
+ for name in os.listdir(goals_dir):
106
+ gpath = os.path.join(goals_dir, name, 'goal.md')
107
+ if not os.path.isfile(gpath):
108
+ continue
109
+ latest = max(latest, os.path.getmtime(gpath))
110
+ try:
111
+ head = open(gpath, encoding='utf-8', errors='replace').read(1200)
112
+ except OSError:
113
+ continue
114
+ m = re.search(r'^status:\s*(\S+)', head, re.M)
115
+ st = m.group(1).strip('"\'') if m else 'unknown'
116
+ counts[st] = counts.get(st, 0) + 1
117
+ return {'counts': counts, 'total': sum(counts.values()), 'latest': latest}
118
+
119
+
120
+ def git_info(root):
121
+ try:
122
+ r = subprocess.run(['git', '-C', root, 'log', '-1', '--format=%ct%x1f%h%x1f%s'],
123
+ capture_output=True, text=True, timeout=5)
124
+ if r.returncode != 0 or not r.stdout.strip():
125
+ return None
126
+ ts, sha, subj = r.stdout.strip().split('\x1f', 2)
127
+ dirty = subprocess.run(['git', '-C', root, 'status', '--porcelain'],
128
+ capture_output=True, text=True, timeout=5).stdout
129
+ return {'ts': int(ts), 'sha': sha, 'subject': subj[:80],
130
+ 'dirty': len([l for l in dirty.splitlines() if l.strip()])}
131
+ except (OSError, subprocess.SubprocessError, ValueError):
132
+ return None
133
+
134
+
135
+ SESSION_ROOT = os.path.join(HOME, '.dsh', 'sessions')
136
+
137
+
138
+ def project_key(cwd):
139
+ out, sep = [], False
140
+ for ch in cwd:
141
+ code = ord(ch)
142
+ if ch in '/\\:':
143
+ if not sep:
144
+ out.append('-')
145
+ sep = True
146
+ elif ch != '~' and (ch.isalnum() or ch in '._-') and code < 128:
147
+ out.append(ch)
148
+ sep = False
149
+ else:
150
+ out.append('~%04X' % code)
151
+ sep = False
152
+ s = ''.join(out).lstrip('-') or 'root'
153
+ return '--' + s[:251] + '--'
154
+
155
+
156
+ def session_activity(root):
157
+ """该项目工作区下最近一次会话活动时间(用于播报里的「最近活动」)。"""
158
+ if not root:
159
+ return 0
160
+ d = os.path.join(SESSION_ROOT, project_key(os.path.realpath(root)))
161
+ if not os.path.isdir(d):
162
+ return 0
163
+ latest = 0
164
+ try:
165
+ for name in os.listdir(d):
166
+ f = os.path.join(d, name, 'session.v3.jsonl.zstd')
167
+ if os.path.exists(f):
168
+ try:
169
+ latest = max(latest, os.path.getmtime(f))
170
+ except OSError:
171
+ pass
172
+ except OSError:
173
+ return 0
174
+ return latest
175
+
176
+
177
+ def in_quiet(cfg, ts=None):
178
+ q = (cfg.get('schedule') or {}).get('quiet') or {}
179
+ start, end = q.get('from'), q.get('to')
180
+ if not start or not end:
181
+ return False
182
+ hm = datetime.fromtimestamp(ts or time.time()).strftime('%H:%M')
183
+ if start <= end:
184
+ return start <= hm < end
185
+ return hm >= start or hm < end
186
+
187
+
188
+ def git_since(root, since, limit=3):
189
+ try:
190
+ r = subprocess.run(['git', '-C', root, 'log', '--since', str(int(since)), '-n', str(limit),
191
+ '--format=%ct%x1f%s'], capture_output=True, text=True, timeout=6)
192
+ if r.returncode != 0:
193
+ return []
194
+ out = []
195
+ for line in r.stdout.strip().splitlines():
196
+ parts = line.split('\x1f', 1)
197
+ if len(parts) == 2:
198
+ out.append({'ts': int(parts[0]), 'subject': parts[1][:80]})
199
+ return out
200
+ except (OSError, subprocess.SubprocessError, ValueError):
201
+ return []
202
+
203
+
204
+ def session_stats(root):
205
+ """返回 {ids:[...], count, latest_at, latest_id}(按会话文件 mtime)。"""
206
+ d = os.path.join(SESSION_ROOT, project_key(os.path.realpath(root or '')))
207
+ out = {'ids': [], 'count': 0, 'latest_at': 0, 'latest_id': None}
208
+ if not os.path.isdir(d):
209
+ return out
210
+ try:
211
+ names = os.listdir(d)
212
+ except OSError:
213
+ return out
214
+ for name in names:
215
+ f = os.path.join(d, name, 'session.v3.jsonl.zstd')
216
+ if not os.path.exists(f):
217
+ continue
218
+ try:
219
+ mt = os.path.getmtime(f)
220
+ except OSError:
221
+ continue
222
+ out['ids'].append(name)
223
+ if mt > out['latest_at']:
224
+ out['latest_at'] = mt
225
+ out['latest_id'] = name
226
+ out['count'] = len(out['ids'])
227
+ return out
228
+
229
+
230
+ def latest_session_topic(root):
231
+ """该项目最近会话的首条用户消息与时间(用于「进展」)。"""
232
+ d = os.path.join(SESSION_ROOT, project_key(os.path.realpath(root or '')))
233
+ if not os.path.isdir(d):
234
+ return None
235
+ best = None
236
+ try:
237
+ for name in os.listdir(d):
238
+ f = os.path.join(d, name, 'session.v3.jsonl.zstd')
239
+ if not os.path.exists(f):
240
+ continue
241
+ mt = os.path.getmtime(f)
242
+ if best is None or mt > best[0]:
243
+ best = (mt, f)
244
+ except OSError:
245
+ return None
246
+ if not best:
247
+ return None
248
+ mt, f = best
249
+ topic = ''
250
+ try:
251
+ proc = subprocess.Popen(['zstd', '-dc', f], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
252
+ raw = b''
253
+ while raw.count(b'\n') < 25 and len(raw) < 200000:
254
+ chunk = proc.stdout.read(65536)
255
+ if not chunk:
256
+ break
257
+ raw += chunk
258
+ proc.kill()
259
+ proc.wait(timeout=5)
260
+ for line in raw.splitlines()[:25]:
261
+ try:
262
+ j = json.loads(line)
263
+ except Exception:
264
+ continue
265
+ if j.get('type') == 'user/message':
266
+ content = (j.get('data') or {}).get('content') or []
267
+ topic = ' '.join(' '.join(c.get('text', '') for c in content if isinstance(c, dict)).split())[:60]
268
+ if topic:
269
+ break
270
+ except Exception:
271
+ pass
272
+ return {'at': mt, 'topic': topic}
273
+
274
+
275
+ def collect(p):
276
+ root = p.get('root', '')
277
+ info = {
278
+ 'id': p['id'], 'name': p.get('name', p['id']), 'kind': p.get('kind', 'code'),
279
+ 'status': p.get('status', 'active'), 'priority': p.get('priority', 50),
280
+ 'exists': os.path.isdir(root), 'next': None, 'state_status': None,
281
+ 'board': None, 'git': None, 'activity': 0, 'memory': bool(p.get('memory')),
282
+ }
283
+ sf = state_file(p) if info['exists'] else None
284
+ if sf:
285
+ nxt, st = parse_state(sf)
286
+ info['next'], info['state_status'] = nxt, st
287
+ info['activity'] = max(info['activity'], os.path.getmtime(sf))
288
+ info['memory'] = True
289
+ if info['exists']:
290
+ info['board'] = board_stats(root)
291
+ if info['board']:
292
+ info['activity'] = max(info['activity'], info['board']['latest'])
293
+ info['git'] = git_info(root)
294
+ if info['git']:
295
+ info['activity'] = max(info['activity'], info['git']['ts'])
296
+ try:
297
+ info['activity'] = max(info['activity'], int(os.path.getmtime(root)))
298
+ except OSError:
299
+ pass
300
+ info['activity'] = max(info['activity'], session_activity(root))
301
+ return info
302
+
303
+
304
+ def rel_time(ts):
305
+ if not ts:
306
+ return '无记录'
307
+ delta = time.time() - ts
308
+ if delta < 90:
309
+ return '刚刚'
310
+ if delta < 3600:
311
+ return f'{int(delta // 60)} 分钟前'
312
+ if delta < 86400:
313
+ return f'{int(delta // 3600)} 小时前'
314
+ return f'{int(delta // 86400)} 天前'
315
+
316
+
317
+ # ── 播报生成 ────────────────────────────────────────────────
318
+ def build_report(cfg):
319
+ rep = cfg.get('report', {})
320
+ md = rep.get('format', 'markdown') == 'markdown'
321
+ reg = load_json(REGISTRY, {'projects': []})
322
+ projects = [p for p in reg.get('projects', []) if p.get('status') != 'archived']
323
+ max_chars = int(rep.get('maxChars', 1200))
324
+
325
+ state_now = load_json(STATE, {})
326
+ prev_run = state_now.get('last_run') or (time.time() - 4 * 3600)
327
+ prev_sessions = state_now.get('sessions') or {}
328
+
329
+ entries = []
330
+ for p in projects:
331
+ info = collect(p)
332
+ prog, changed = [], False
333
+ commits = git_since(p.get('root', ''), prev_run) if info['exists'] else []
334
+ if commits:
335
+ prog.append(f"git {len(commits)} 次提交({commits[0]['subject'][:40]})")
336
+ changed = True
337
+ if info['git'] and info['git']['dirty']:
338
+ prog.append(f"{info['git']['dirty']} 个文件待提交")
339
+ changed = True
340
+ stats = session_stats(p.get('root', '')) if info['exists'] else {'ids': [], 'count': 0, 'latest_at': 0, 'latest_id': None}
341
+ prev = prev_sessions.get(p['id']) or {}
342
+ prev_ids = set(prev.get('ids') or [])
343
+ new_ids = [i for i in stats['ids'] if i not in prev_ids] if prev_ids else []
344
+ if new_ids:
345
+ prog.append(f"新增 {len(new_ids)} 个会话(共 {stats['count']})")
346
+ changed = True
347
+ elif stats['latest_at'] and stats['latest_at'] > float(prev.get('latest_at') or 0):
348
+ prog.append(f"会话更新({rel_time(stats['latest_at'])})")
349
+ changed = True
350
+ elif stats['count'] and not prev_ids:
351
+ prog.append(f"会话 {stats['count']} 个(最近 {rel_time(stats['latest_at'])})")
352
+ topic = latest_session_topic(p.get('root', '')) if (info['exists'] and (new_ids or stats['latest_at'] > prev_run)) else None
353
+ if topic and topic['topic']:
354
+ prog.append(f"「{topic['topic'][:36]}」")
355
+ if info['board'] and info['board']['total']:
356
+ prog.append(f"看板 {info['board']['total']} 个目标")
357
+ changed = True
358
+ nxt = info['next'] or info.get('state_status') or p.get('note') or '(未登记进展)'
359
+ if str(nxt).startswith('自动发现:'):
360
+ nxt = '待确认项目目标与下一步(自动发现)'
361
+ stale = bool(info['activity']) and time.time() - info['activity'] > 14 * 86400
362
+ entries.append({'p': p, 'info': info, 'prog': prog, 'changed': changed, 'next': nxt,
363
+ 'stale': stale, 'priority': p.get('priority', 50)})
364
+
365
+ entries.sort(key=lambda e: (not e['changed'], -e['priority'], e['p']['id']))
366
+ blocks, unchanged = [], []
367
+ for e in entries:
368
+ info, p = e['info'], e['p']
369
+ if not e['changed']:
370
+ unchanged.append(info['name'])
371
+ continue
372
+ rel = rel_time(info['activity'])
373
+ missing = '' if info['exists'] else ' · ⚠ 目录不存在'
374
+ stale_hint = ' · ⚠ 14 天未动' if e['stale'] else ''
375
+ name, kind = info['name'], KINDS.get(info['kind'], info['kind'])
376
+ blk = [f'- **{name}**({kind})· {rel}{missing}{stale_hint}' if md else f'{name}({kind})· {rel}{missing}{stale_hint}']
377
+ blk.append(' 进展:' + (' · '.join(e['prog']) if e['prog'] else '本期无新变化'))
378
+ blk.append(f" 下一步:{e['next']}")
379
+ bits = []
380
+ if info['git']:
381
+ g = info['git']
382
+ bits.append(f"git {g['sha']}" + (f" +{g['dirty']} 改动" if g['dirty'] else ''))
383
+ if not info['memory']:
384
+ bits.append('未落盘')
385
+ if bits:
386
+ if md:
387
+ bits = [f'`{b}`' for b in bits]
388
+ blk.append(' ' + (' · '.join(bits) if md else ' / '.join(bits)))
389
+ blocks.append('\n'.join(blk))
390
+
391
+ blocked = [p for p in projects if p.get('status') == 'blocked']
392
+ active = [p for p in projects if p.get('status') == 'active']
393
+ infos = [e['info'] for e in entries]
394
+ recent = [i for i in infos if i['activity'] and time.time() - i['activity'] < 6 * 3600]
395
+ now = datetime.now()
396
+
397
+ if md:
398
+ lines = [f'**项目播报** · {now:%m-%d %H:%M}', '',
399
+ f'{len(projects)} 个项目|活跃 {len(active)} · 阻塞 {len(blocked)} · 近 6 小时更新 {len(recent)}', '']
400
+ else:
401
+ lines = [f'【项目播报 {now:%m-%d %H:%M}】',
402
+ f'项目 {len(projects)} 个(活跃 {len(active)},阻塞 {len(blocked)}),近 6 小时有更新 {len(recent)} 个', '']
403
+
404
+ scan_st = load_json(SCAN_STATE, {})
405
+ if scan_st.get('lastRun') and time.time() - scan_st['lastRun'] < 7 * 86400:
406
+ added = scan_st.get('added') or []
407
+ cands = scan_st.get('candidates') or []
408
+ if added:
409
+ lines.append('自动发现:新增 ' + str(len(added)) + ' 个项目(' + '、'.join(a['name'] for a in added[:3]) + ')')
410
+ elif cands:
411
+ lines.append(f'自动发现:{len(cands)} 个候选待确认(deck scan)')
412
+ if added or cands:
413
+ lines.append('')
414
+
415
+ # 节点离线提醒(超过 3 天没上报)
416
+ try:
417
+ off = []
418
+ for n in load_nodes().get('nodes', []):
419
+ if n.get('local'):
420
+ continue
421
+ ls = float(n.get('lastSeen') or 0)
422
+ if not ls:
423
+ off.append(f"{n.get('name') or n['id']}(从未上报)")
424
+ elif time.time() - ls > 3 * 86400:
425
+ off.append(f"{n.get('name') or n['id']}({int((time.time()-ls)//86400)} 天)")
426
+ if off:
427
+ lines.append('节点离线:' + '、'.join(off))
428
+ lines.append('')
429
+ except Exception:
430
+ pass
431
+
432
+ budget = max_chars - len('\n'.join(lines)) - 200
433
+ keep, total = [], 0
434
+ for b in blocks:
435
+ if total + len(b) + 1 > budget:
436
+ break
437
+ keep.append(b)
438
+ total += len(b) + 1
439
+ omitted = len(blocks) - len(keep)
440
+ parts = keep[:]
441
+ if unchanged:
442
+ parts.append('无变化:' + '、'.join(unchanged[:8]) + ('…' if len(unchanged) > 8 else ''))
443
+ if omitted:
444
+ parts.append(f'……另有 {omitted} 个有变化的项目未展开')
445
+ sep = '\n' if md else '\n\n'
446
+ lines.append(sep.join(parts))
447
+ if blocked:
448
+ names = '、'.join(p.get('name', p['id']) for p in blocked)
449
+ lines.append('')
450
+ lines.append(f'**阻塞**:{names}' if md else f'阻塞:{names}')
451
+ return '\n'.join(lines)
452
+
453
+
454
+ # ── 自动发现 ────────────────────────────────────────────────
455
+ WS_STORE = os.path.join(HOME, '.dsh', 'storages', 'workspace.json')
456
+ SCAN_STATE = os.path.join(BASE, 'scan-state.json')
457
+ SCAN_IGNORE = os.path.join(BASE, 'scan-ignore.json')
458
+ NODES = os.path.join(BASE, 'nodes.json')
459
+ NODE_SNAPS = os.path.join(BASE, 'nodes')
460
+
461
+ # 不参与扫描的目录(宿主/缓存/系统/已有项目内部)
462
+ EXCLUDE_NAMES = {'.git', 'node_modules', '.venv', 'venv', '__pycache__', 'Library',
463
+ 'Applications', 'Movies', 'Music', 'Pictures', 'Public', 'Downloads',
464
+ '.npm', '.cache', '.Trash', '.DS_Store', 'dist', 'build', 'target',
465
+ 'go', 'vendor', 'attachments', 'logs'}
466
+ MARKERS = ['.git', 'package.json', 'pnpm-workspace.yaml', 'pyproject.toml', 'setup.py',
467
+ 'go.mod', 'Cargo.toml', 'AGENTS.md', 'CLAUDE.md', '.dsh-graph', 'README.md']
468
+
469
+
470
+ def dsh_workspaces():
471
+ """DSH 工作区 = 用户真实用过的「工作分区」,带会话数与最近活跃时间。"""
472
+ data = load_json(WS_STORE, {})
473
+ out = {}
474
+ for wid, w in (data.get('tables', {}).get('workspaces') or {}).items():
475
+ path = os.path.realpath(w.get('path', '') or '')
476
+ if not path:
477
+ continue
478
+ sids = w.get('sessionIds') or []
479
+ latest = 0
480
+ for sid in sids:
481
+ for f in glob.glob(os.path.join(HOME, '.dsh', 'sessions', '*', sid + '*')):
482
+ try:
483
+ latest = max(latest, os.path.getmtime(f))
484
+ except OSError:
485
+ pass
486
+ out[path] = {'path': path, 'title': w.get('title') or os.path.basename(path),
487
+ 'sessions': len(sids), 'last': latest}
488
+ return out
489
+
490
+
491
+ def is_excluded(path):
492
+ parts = set(path.split(os.sep))
493
+ if parts & EXCLUDE_NAMES:
494
+ return True
495
+ if path.startswith(os.path.join(HOME, '.dsh')) or path.startswith(os.path.join(HOME, '.ai')):
496
+ return True
497
+ if path.startswith(os.path.join(HOME, 'Library')):
498
+ return True
499
+ return False
500
+
501
+
502
+ def _marker_info(path):
503
+ found = []
504
+ latest = 0
505
+ for m in MARKERS:
506
+ fp = os.path.join(path, m)
507
+ if os.path.exists(fp):
508
+ found.append(m)
509
+ if m == '.git':
510
+ continue # .git 的 mtime 会被 git 自身刷新,不代表项目活跃
511
+ try:
512
+ latest = max(latest, os.path.getmtime(fp))
513
+ except OSError:
514
+ pass
515
+ return found, latest
516
+
517
+
518
+ def scan_filesystem(cfg):
519
+ scan = cfg.get('scan', {})
520
+ roots = scan.get('roots', [os.path.join(HOME, 'Desktop'), HOME])
521
+ depth = int(scan.get('depth', 1))
522
+ found = {}
523
+ for r in roots:
524
+ base = os.path.realpath(os.path.expanduser(r))
525
+ if not os.path.isdir(base):
526
+ continue
527
+ # 扫描根本身不是项目(例如家目录 / 桌面)
528
+ found.pop(base, None)
529
+ level = [(base, 0)]
530
+ while level:
531
+ cur, d = level.pop()
532
+ if d >= depth:
533
+ continue
534
+ try:
535
+ entries = sorted(os.scandir(cur), key=lambda e: e.name)
536
+ except OSError:
537
+ continue
538
+ for e in entries:
539
+ if not e.is_dir(follow_symlinks=False) or e.name.startswith('.'):
540
+ continue
541
+ if e.name in EXCLUDE_NAMES:
542
+ continue
543
+ sub = os.path.realpath(e.path)
544
+ if is_excluded(sub):
545
+ continue
546
+ markers, latest = _marker_info(sub)
547
+ if markers:
548
+ found[sub] = {'markers': markers, 'last': latest}
549
+ # 只对根目录的一级做 marker 判断;更深层仅当自身像项目才继续
550
+ level.append((sub, d + 1))
551
+ return found
552
+
553
+
554
+ def load_nodes():
555
+ return load_json(NODES, {'version': 1, 'nodes': []})
556
+
557
+
558
+ def find_node(node_id):
559
+ return next((n for n in load_nodes().get('nodes', []) if n.get('id') == node_id), None)
560
+
561
+
562
+ def remote_snapshots():
563
+ out = []
564
+ if not os.path.isdir(NODE_SNAPS):
565
+ return out
566
+ for f in sorted(glob.glob(os.path.join(NODE_SNAPS, '*.json'))):
567
+ snap = load_json(f, None)
568
+ if isinstance(snap, dict) and snap.get('node'):
569
+ out.append(snap)
570
+ return out
571
+
572
+
573
+ def save_node_scan(payload):
574
+ node_id = str(payload.get('node') or '').strip()
575
+ node = find_node(node_id)
576
+ if not node:
577
+ raise ValueError(f'未登记的节点 {node_id}')
578
+ token = str(payload.get('token') or '')
579
+ import hmac
580
+ if not token or not hmac.compare_digest(token, str(node.get('token') or '')):
581
+ raise ValueError('节点 token 不正确')
582
+ projects = payload.get('projects') or []
583
+ if not isinstance(projects, list) or len(projects) > 2000:
584
+ raise ValueError('projects 字段非法')
585
+ snap = {'node': node_id, 'name': node.get('name') or node_id,
586
+ 'platform': node.get('platform', ''), 'scanned_at': time.time(),
587
+ 'projects': projects}
588
+ os.makedirs(NODE_SNAPS, exist_ok=True)
589
+ save_json(os.path.join(NODE_SNAPS, node_id + '.json'), snap)
590
+ node['lastSeen'] = time.time()
591
+ nodes = load_nodes()
592
+ for n in nodes['nodes']:
593
+ if n['id'] == node_id:
594
+ n['lastSeen'] = node['lastSeen']
595
+ save_json(NODES, nodes)
596
+ log(f'[node] {node_id} 上报 {len(projects)} 个项目')
597
+ return snap
598
+
599
+
600
+ def cmd_nodes(argv):
601
+ nodes = load_nodes()
602
+ if not argv:
603
+ if not nodes['nodes']:
604
+ print('还没有登记节点。deck nodes add --id hos --name "HOS 手机" --platform android')
605
+ return 0
606
+ print(f"{'id':16} {'名称':14} {'平台':10} {'最近上报':12} {'项目':>4} 状态")
607
+ for n in nodes['nodes']:
608
+ snap = load_json(os.path.join(NODE_SNAPS, n['id'] + '.json'), None)
609
+ last = snap.get('scanned_at') if snap else None
610
+ print(f"{n['id']:16} {(n.get('name') or '')[:12]:14} {n.get('platform',''):10} "
611
+ f"{rel_time(last) if last else '从未':12} {len(snap.get('projects', [])) if snap else 0:>4} "
612
+ f"{'在线' if last and time.time() - last < 86400 else '离线'}")
613
+ return 0
614
+ action = argv[0]
615
+ if action == 'add':
616
+ opts = {}
617
+ i = 1
618
+ while i < len(argv):
619
+ if argv[i].startswith('--') and i + 1 < len(argv):
620
+ opts[argv[i][2:]] = argv[i + 1]
621
+ i += 2
622
+ else:
623
+ i += 1
624
+ nid = opts.get('id')
625
+ if not nid:
626
+ print('缺少 --id')
627
+ return 1
628
+ if find_node(nid):
629
+ print(f'节点 {nid} 已存在')
630
+ return 1
631
+ nodes['nodes'].append({
632
+ 'id': nid, 'name': opts.get('name', nid), 'platform': opts.get('platform', ''),
633
+ 'address': opts.get('address', ''), 'roots': opts.get('roots', ''),
634
+ 'token': secrets.token_urlsafe(24), 'createdAt': time.time(),
635
+ })
636
+ save_json(NODES, nodes)
637
+ n = find_node(nid)
638
+ print(f'已登记节点 {nid},token: {n["token"]}')
639
+ print('把 token 和服务器地址写进节点上的 ~/.deck-node.json:')
640
+ print(' {"server":"http://192.168.1.3:9199","token":"%s","id":"%s"}' % (n['token'], nid))
641
+ return 0
642
+ if action == 'rm':
643
+ if not argv[1:]:
644
+ return 1
645
+ nodes['nodes'] = [n for n in nodes['nodes'] if n['id'] != argv[1]]
646
+ save_json(NODES, nodes)
647
+ print('已移除节点', argv[1])
648
+ return 0
649
+ print('用法: deck nodes [add --id X --name Y --platform android | rm X]')
650
+ return 1
651
+
652
+
653
+ def slugify(text):
654
+ s = re.sub(r'[^a-z0-9]+', '-', (text or '').lower()).strip('-')
655
+ return s or 'project'
656
+
657
+
658
+ def load_ignore():
659
+ return load_json(SCAN_IGNORE, {'ignore': {}, 'blocked': []})
660
+
661
+
662
+ def is_ignored(path):
663
+ ig = load_ignore()
664
+ now = time.time()
665
+ for prefix in ig.get('blocked', []):
666
+ if path == prefix or path.startswith(prefix.rstrip('/') + '/'):
667
+ return True
668
+ until = (ig.get('ignore') or {}).get(path)
669
+ return bool(until and until > now)
670
+
671
+
672
+ def discover(cfg=None):
673
+ cfg = cfg or load_json(CONFIG, {})
674
+ scan = cfg.get('scan', {})
675
+ threshold = int(scan.get('threshold', 70))
676
+ reg = load_json(REGISTRY, {'projects': []})
677
+ known = {os.path.realpath(p.get('root', '')): p for p in reg['projects']}
678
+ ws = dsh_workspaces()
679
+ fs = scan_filesystem(cfg)
680
+ now = time.time()
681
+ paths = set(ws) | set(fs)
682
+ out = []
683
+ home = os.path.realpath(HOME)
684
+ for path in sorted(paths):
685
+ if path in (home, os.path.realpath(os.path.expanduser('~/Desktop'))):
686
+ continue
687
+ if path in known or is_excluded(path) or is_ignored(path):
688
+ continue
689
+ # 嵌套在已登记项目内部的目录不算新项目
690
+ if any(path != root and path.startswith(root + os.sep) for root in known):
691
+ continue
692
+ if not os.path.isdir(path):
693
+ continue
694
+ info = ws.get(path, {})
695
+ markers = fs.get(path, {}).get('markers', [])
696
+ git = git_info(path)
697
+ score, reasons = 0, []
698
+ if info:
699
+ score += 50
700
+ reasons.append(f'DSH 工作区({info["sessions"]} 个会话)')
701
+ if info['last'] and now - info['last'] < 30 * 86400:
702
+ score += 15
703
+ reasons.append('近 30 天用过')
704
+ elif info['last'] and now - info['last'] < 90 * 86400:
705
+ score += 5
706
+ if git:
707
+ score += 30
708
+ reasons.append(f'git 仓库({git["subject"][:24]})')
709
+ if git['ts'] and now - git['ts'] < 30 * 86400:
710
+ score += 10
711
+ reasons.append('近 30 天有提交')
712
+ if markers:
713
+ score += 5 * min(len(markers), 2)
714
+ reasons.append('含 ' + ' / '.join(markers[:3]))
715
+ activity = max([info.get('last', 0), fs.get(path, {}).get('last', 0),
716
+ git['ts'] if git else 0,
717
+ os.path.getmtime(path) if os.path.exists(path) else 0])
718
+ name = info.get('title') or os.path.basename(path)
719
+ out.append({
720
+ 'path': path, 'name': name, 'id': slugify(name), 'kind': 'code',
721
+ 'score': min(score, 100), 'reasons': reasons, 'git': git,
722
+ 'sessions': info.get('sessions', 0), 'last': activity,
723
+ 'workspace': bool(info), 'auto': score >= threshold,
724
+ })
725
+ # 远程节点上报的项目
726
+ seen_paths = {c['path'] for c in out}
727
+ for snap in remote_snapshots():
728
+ node_id = snap.get('node')
729
+ for rp in snap.get('projects') or []:
730
+ path = str(rp.get('path') or '').strip()
731
+ if not path or path in known or path in seen_paths:
732
+ continue
733
+ seen_paths.add(path)
734
+ markers = rp.get('markers') or []
735
+ git = rp.get('git')
736
+ score = 25
737
+ reasons = [f'来自节点 {snap.get("name") or node_id}']
738
+ if git:
739
+ score += 30
740
+ reasons.append('git 仓库')
741
+ if markers:
742
+ score += 5 * min(len(markers), 2)
743
+ reasons.append('含 ' + ' / '.join(markers[:2]))
744
+ last = float(rp.get('last') or snap.get('scanned_at') or 0)
745
+ last = min(last, now)
746
+ if last and now - last < 30 * 86400:
747
+ score += 10
748
+ reasons.append('近 30 天有改动')
749
+ out.append({
750
+ 'path': path, 'name': rp.get('name') or os.path.basename(path),
751
+ 'id': slugify(rp.get('name') or os.path.basename(path)), 'kind': rp.get('kind', 'code'),
752
+ 'score': min(score, 100), 'reasons': reasons, 'git': git, 'sessions': 0,
753
+ 'last': last, 'workspace': False, 'node': node_id,
754
+ 'auto': score >= threshold and node_id in {n['id'] for n in load_nodes().get('nodes', [])},
755
+ })
756
+ out.sort(key=lambda x: (-x['score'], -x['last']))
757
+ return out
758
+
759
+
760
+ def auto_scan(cfg=None):
761
+ """扫描 + 自动登记高置信度候选,返回本次结果摘要。"""
762
+ cfg = cfg or load_json(CONFIG, {})
763
+ cands = discover(cfg)
764
+ auto = [c for c in cands if c['auto']]
765
+ added = []
766
+ if cfg.get('scan', {}).get('autoAdd', True) and auto:
767
+ reg = load_json(REGISTRY, {'projects': []})
768
+ used = {p['id'] for p in reg['projects']}
769
+ for c in auto:
770
+ pid = c['id']
771
+ n = 2
772
+ while pid in used:
773
+ pid = f"{c['id']}-{n}"
774
+ n += 1
775
+ used.add(pid)
776
+ reg['projects'].append({
777
+ 'id': pid, 'name': c['name'], 'aliases': [pid, c['name']],
778
+ 'kind': c['kind'], 'status': 'active', 'priority': 40,
779
+ 'root': c['path'], 'memory': None,
780
+ 'note': '自动发现:' + ';'.join(c['reasons'][:3]),
781
+ 'created_at': datetime.now().astimezone().isoformat(timespec='seconds'),
782
+ })
783
+ added.append({'id': pid, 'name': c['name'], 'path': c['path'], 'score': c['score']})
784
+ if added:
785
+ save_json(REGISTRY, reg)
786
+ st = load_json(SCAN_STATE, {})
787
+ cands_sorted = sorted([c for c in cands if not c['auto']], key=lambda c: -c['score'])[:30]
788
+ st.update({'lastRun': time.time(), 'added': added, 'candidates': cands_sorted})
789
+ save_json(SCAN_STATE, st)
790
+ log(f"[scan] 候选 {len(cands)},自动登记 {len(added)}")
791
+ return {'added': added, 'candidates': st['candidates'], 'at': st['lastRun']}
792
+
793
+
794
+ def cmd_scan(argv):
795
+ cfg = load_json(CONFIG, {})
796
+ if '--add' in argv:
797
+ res = auto_scan(cfg)
798
+ print(f"自动登记 {len(res['added'])} 个,剩余候选 {len(res['candidates'])} 个")
799
+ for a in res['added']:
800
+ print(f" + {a['id']:20} {a['name'][:16]:18} {a['path']}")
801
+ return 0
802
+ cands = discover(cfg)
803
+ if not cands:
804
+ print('没有发现新项目候选')
805
+ return 0
806
+ print(f"{'✓':2} {'score':5} {'id':20} {'名称':16} {'最近活动':10} 路径")
807
+ for c in cands:
808
+ mark = '·' if c['auto'] else '?'
809
+ print(f"{mark:2} {c['score']:<5} {c['id']:20} {c['name'][:14]:16} "
810
+ f"{rel_time(c['last']):10} {c['path']}")
811
+ print(f" 理由:{';'.join(c['reasons']) or '—'}")
812
+ print(f"\n共 {len(cands)} 个;· = 达到自动登记阈值,? = 仅候选。"
813
+ f"执行 deck scan --add 自动登记高置信度项目。")
814
+ return 0
815
+
816
+
817
+ # ── 发送 ────────────────────────────────────────────────────
818
+ def channel_state():
819
+ return load_json(CHANNEL_STATE, {'stale': False, 'fails': 0})
820
+
821
+
822
+ def _ctx_hash():
823
+ st = load_json(WXPUSH_STATE, {})
824
+ token = st.get('context_token') or ''
825
+ return hashlib.sha256(token.encode()).hexdigest()[:16]
826
+
827
+
828
+ def channel_stale():
829
+ st = channel_state()
830
+ if not st.get('stale'):
831
+ return False
832
+ # 用户给 bot 发过消息 → context_token 变了 → 自动解除 stale
833
+ if st.get('ctx') and st['ctx'] != _ctx_hash():
834
+ save_json(CHANNEL_STATE, {'stale': False, 'fails': 0, 'lastOk': time.time()})
835
+ log('[channel] 检测到 context_token 已刷新,恢复播报')
836
+ return False
837
+ return True
838
+
839
+
840
+ def mark_channel(ok, err=''):
841
+ st = channel_state()
842
+ if ok:
843
+ st = {'stale': False, 'fails': 0, 'lastOk': time.time()}
844
+ else:
845
+ st['fails'] = int(st.get('fails', 0)) + 1
846
+ st['ctx'] = _ctx_hash()
847
+ st['lastError'] = (err or '')[:200]
848
+ st['at'] = time.time()
849
+ if st['fails'] >= 2:
850
+ st['stale'] = True
851
+ save_json(CHANNEL_STATE, st)
852
+
853
+
854
+ def send_wechat(text):
855
+ if not os.path.exists(WXSEND):
856
+ return 3, 'wxsend 不存在'
857
+ r = subprocess.run([WXSEND], input=text, capture_output=True, text=True, timeout=30)
858
+ msg = (r.stdout or r.stderr).strip()
859
+ if r.returncode == 0:
860
+ mark_channel(True)
861
+ elif r.returncode == 2:
862
+ mark_channel(False, msg.splitlines()[0] if msg else 'queued')
863
+ return r.returncode, msg
864
+
865
+
866
+ def refresh_local_node(cfg=None):
867
+ """把本机也当作一个节点:播报前刷新一次快照,面板里就不会显示「离线」。"""
868
+ cfg = cfg or load_json(CONFIG, {})
869
+ nodes = load_nodes()
870
+ node = next((n for n in nodes.get('nodes', []) if n.get('local')), None)
871
+ if not node:
872
+ return
873
+ fs = scan_filesystem(cfg)
874
+ projects = []
875
+ for path, meta in fs.items():
876
+ projects.append({'path': path, 'name': os.path.basename(path), 'markers': meta.get('markers', []),
877
+ 'git': git_info(path), 'last': meta.get('last', 0)})
878
+ try:
879
+ save_node_scan({'node': node['id'], 'token': node.get('token', ''),
880
+ 'platform': 'darwin', 'projects': projects})
881
+ log(f'[node] 本机快照已刷新({len(projects)} 个项目)')
882
+ except ValueError as e:
883
+ log(f'[node] 本机快照刷新失败: {e}')
884
+
885
+
886
+ def save_session_snapshot():
887
+ st = load_json(STATE, {})
888
+ reg = load_json(REGISTRY, {'projects': []})
889
+ snap = {}
890
+ for p in reg.get('projects', []):
891
+ snap[p['id']] = session_stats(p.get('root', ''))
892
+ st['sessions'] = snap
893
+ save_json(STATE, st)
894
+
895
+
896
+ def do_report(cfg, quiet=False):
897
+ text = build_report(cfg)
898
+ if channel_stale():
899
+ log('[report] 跳过:微信通道 context_token 过期(等你给 bot 发消息刷新)')
900
+ if not quiet:
901
+ print('⚠️ 微信通道过期,本次播报已跳过(不排队);在微信里给 bot 发任意一句话即可恢复')
902
+ return 2, text, 'SKIP_CHANNEL_STALE'
903
+ with open(LAST, 'w') as f:
904
+ f.write(text + '\n')
905
+ code, msg = send_wechat(text)
906
+ try:
907
+ save_session_snapshot()
908
+ except Exception as e:
909
+ log(f'[report] 会话快照失败: {e!r}')
910
+ try:
911
+ refresh_local_node(cfg)
912
+ except Exception as e:
913
+ log(f'[node] 本机快照刷新异常: {e!r}')
914
+ log(f'report exit={code} {msg.splitlines()[-1] if msg else ""}')
915
+ # 播报历史(面板读取)
916
+ try:
917
+ with open(HISTORY, 'a') as h:
918
+ h.write(json.dumps({'at': time.time(), 'exit': code, 'text': text},
919
+ ensure_ascii=False) + '\n')
920
+ except OSError:
921
+ pass
922
+ if not quiet:
923
+ print(text)
924
+ print(f'\n[wechat exit {code}] {msg}')
925
+ return code, text, msg
926
+
927
+
928
+
929
+ def do_report_with_image(cfg, quiet=False, force=None):
930
+ """播报(文字)+ 可选附图;force=True/False 覆盖 config,None 跟随 config。"""
931
+ code, text, msg = do_report(cfg, quiet)
932
+ want = cfg.get('report', {}).get('image') if force is None else force
933
+ if code == 0 and want:
934
+ try:
935
+ png = render_image(cfg)
936
+ icode, imsg = send_wechat_image(png)
937
+ if icode != 0:
938
+ queue_image(png, None)
939
+ log(f'[image] 附图 exit={icode} {imsg}')
940
+ if not quiet:
941
+ print(f'[image exit {icode}] {imsg}')
942
+ except Exception as e:
943
+ log(f'[image] 附图失败: {e!r}')
944
+ if not quiet:
945
+ print('附图失败:', e)
946
+ return code, text, msg
947
+
948
+
949
+ # ── 微信图片播报 ────────────────────────────────────────────
950
+ WXPUSH_STATE = os.environ.get('WXPUSH_STATE', os.path.join(HOME, '.wxpush', 'state.json'))
951
+ CDN_BASE = 'https://novac2c.cdn.weixin.qq.com/c2c'
952
+ CHROME = os.environ.get('DECK_CHROME', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')
953
+ GRAPHICS = os.path.join(BASE, 'graphics')
954
+
955
+
956
+ def _wx_state():
957
+ st = load_json(WXPUSH_STATE, {})
958
+ return st.get('bot_token'), st.get('context_token'), st.get('ilink_user_id'), \
959
+ (st.get('api_base') or 'https://ilinkai.weixin.qq.com/ilink')
960
+
961
+
962
+ def _wx_headers(token):
963
+ uin = base64.b64encode(str(secrets.randbelow(0xffffffff)).encode()).decode()
964
+ return {'content-type': 'application/json', 'iLink-App-ClientVersion': '1',
965
+ 'X-WECHAT-UIN': uin, 'Authorization': f'Bearer {token}',
966
+ 'AuthorizationType': 'ilink_bot_token'}
967
+
968
+
969
+ def _wx_post(url, body, token, timeout=30):
970
+ req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=_wx_headers(token))
971
+ with urllib.request.urlopen(req, timeout=timeout) as r:
972
+ return json.loads(r.read().decode())
973
+
974
+
975
+ def _aes_ecb(data, key):
976
+ size = ((len(data) + 1 + 15) // 16) * 16
977
+ plen = 16 - (len(data) % 16)
978
+ padded = data + bytes([plen]) * plen
979
+ try:
980
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
981
+ enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
982
+ return enc.update(padded) + enc.finalize(), size
983
+ except ImportError:
984
+ # 系统 Python 3.9 没有 cryptography:退回 macOS 自带 openssl
985
+ r = subprocess.run(['/usr/bin/openssl', 'enc', '-aes-128-ecb', '-K', key.hex(),
986
+ '-nosalt', '-nopad'], input=padded, capture_output=True)
987
+ if r.returncode != 0:
988
+ raise RuntimeError('AES 加密失败: ' + (r.stderr.decode()[:120]))
989
+ return r.stdout, size
990
+
991
+
992
+ PENDING_IMG = os.path.join(BASE, 'pending-image.json')
993
+
994
+
995
+ def queue_image(png_path, caption=None):
996
+ save_json(PENDING_IMG, {'path': png_path, 'caption': caption, 'at': time.time()})
997
+ log(f'[image] 已入队待补发 {os.path.basename(png_path)}')
998
+
999
+
1000
+ def flush_pending_image():
1001
+ p = load_json(PENDING_IMG, None)
1002
+ if not p:
1003
+ return False
1004
+ if channel_stale():
1005
+ return False
1006
+ # 失败后 2 分钟内不重复尝试,避免空转
1007
+ if time.time() - float(p.get('lastTry', 0)) < 120:
1008
+ return False
1009
+ p['lastTry'] = time.time()
1010
+ save_json(PENDING_IMG, p)
1011
+ path = p.get('path', '')
1012
+ if not os.path.exists(path):
1013
+ os.remove(PENDING_IMG)
1014
+ return False
1015
+ code, msg = send_wechat_image(path, p.get('caption'))
1016
+ if code == 0:
1017
+ try:
1018
+ os.remove(PENDING_IMG)
1019
+ except OSError:
1020
+ pass
1021
+ log('[image] 队列图片补发成功')
1022
+ return True
1023
+ if time.time() - float(p.get('at', 0)) > 3 * 86400:
1024
+ try:
1025
+ os.remove(PENDING_IMG)
1026
+ except OSError:
1027
+ pass
1028
+ log('[image] 队列图片超过 3 天,放弃')
1029
+ return False
1030
+
1031
+
1032
+ def send_wechat_image(png_path, caption=None):
1033
+ token, ctx, uid, api = _wx_state()
1034
+ if not token or not ctx or not uid:
1035
+ return 2, 'wxpush 未授权(token/context_token 缺失)'
1036
+ try:
1037
+ raw = open(png_path, 'rb').read()
1038
+ except OSError as e:
1039
+ return 1, f'读图失败: {e}'
1040
+ rawsize = len(raw)
1041
+ rawmd5 = hashlib.md5(raw).hexdigest()
1042
+ key = secrets.token_bytes(16)
1043
+ ciphertext, filesize = _aes_ecb(raw, key)
1044
+ filekey = secrets.token_hex(16)
1045
+ up = _wx_post(api + '/bot/getuploadurl', {
1046
+ 'filekey': filekey, 'media_type': 1, 'to_user_id': uid,
1047
+ 'rawsize': rawsize, 'rawfilemd5': rawmd5, 'filesize': filesize,
1048
+ 'no_need_thumb': True, 'aeskey': key.hex()}, token)
1049
+ url = up.get('upload_full_url') or up.get('upload_param')
1050
+ if not url:
1051
+ return 1, f'getuploadurl 失败: {str(up)[:120]}'
1052
+ if not str(url).startswith('http'):
1053
+ url = f'{CDN_BASE}/upload?encrypted_query_param={urllib.parse.quote(str(url))}&filekey={filekey}'
1054
+ req = urllib.request.Request(url, data=ciphertext,
1055
+ headers={'content-type': 'application/octet-stream'})
1056
+ with urllib.request.urlopen(req, timeout=120) as r:
1057
+ if r.status != 200:
1058
+ return 1, f'CDN 上传 HTTP {r.status}'
1059
+ download_param = r.headers.get('x-encrypted-param')
1060
+ if not download_param:
1061
+ return 1, 'CDN 上传响应缺少 x-encrypted-param'
1062
+ aes_b64 = base64.b64encode(key.hex().encode()).decode()
1063
+ body = {'msg': {'from_user_id': '', 'to_user_id': uid,
1064
+ 'client_id': f'deck:{int(time.time())}:{secrets.randbelow(999999)}',
1065
+ 'message_type': 2, 'message_state': 2, 'context_token': ctx,
1066
+ 'item_list': [{'type': 2, 'image_item': {
1067
+ 'media': {'encrypt_query_param': download_param, 'aes_key': aes_b64,
1068
+ 'encrypt_type': 1},
1069
+ 'mid_size': filesize}}]},
1070
+ 'base_info': {'channel_version': '1.0.0'}}
1071
+ res = _wx_post(api + '/bot/sendmessage', body, token)
1072
+ if res.get('ret', 0) != 0 and not res.get('message_id'):
1073
+ return 1, f'图片发送失败: {str(res)[:160]}'
1074
+ if caption:
1075
+ code, msg = send_wechat(caption)
1076
+ if code != 0:
1077
+ return code, '图片已发,说明文字未送达:' + msg
1078
+ log(f'[image] 已发送 {os.path.basename(png_path)} ({rawsize}B)')
1079
+ return 0, '图片已送达微信'
1080
+
1081
+
1082
+ def dashboard_html(cfg, limit=8):
1083
+ reg = load_json(REGISTRY, {'projects': []})
1084
+ projects = [p for p in reg.get('projects', []) if p.get('status') != 'archived']
1085
+ projects.sort(key=lambda p: (-p.get('priority', 50), p['id']))
1086
+ infos = [(p, collect(p)) for p in projects[:limit]]
1087
+ active = sum(1 for p in projects if p.get('status') == 'active')
1088
+ blocked = sum(1 for p in projects if p.get('status') == 'blocked')
1089
+ recent = sum(1 for _, i in infos if i['activity'] and time.time() - i['activity'] < 6 * 3600)
1090
+ st = load_json(STATE, {})
1091
+ rep = cfg.get('report', {})
1092
+ nxt = (st.get('last_run') or 0) + float(rep.get('intervalHours', 4)) * 3600
1093
+ scan_st = load_json(SCAN_STATE, {})
1094
+ scan_line = ''
1095
+ if scan_st.get('lastRun'):
1096
+ added = scan_st.get('added') or []
1097
+ cands = scan_st.get('candidates') or []
1098
+ if added:
1099
+ scan_line = '自动发现:新增 ' + str(len(added)) + ' 个项目(' + '、'.join(a['name'] for a in added[:3]) + ')'
1100
+ elif cands:
1101
+ scan_line = '自动发现:' + str(len(cands)) + ' 个候选待确认'
1102
+ rows = []
1103
+ for p, info in infos:
1104
+ bits = []
1105
+ if info['git']:
1106
+ bits.append('git ' + info['git']['sha'] + ((' +' + str(info['git']['dirty'])) if info['git']['dirty'] else ''))
1107
+ if info['board'] and info['board']['total']:
1108
+ bits.append('看板 ' + str(info['board']['total']))
1109
+ if not info['memory']:
1110
+ bits.append('未落盘')
1111
+ nxt_action = info['next'] or p.get('note') or '(未登记进展)'
1112
+ status_cn = {'active': '活跃', 'paused': '暂停', 'blocked': '阻塞', 'archived': '归档'}.get(p.get('status', 'active'), p.get('status'))
1113
+ rows.append(
1114
+ '<div class="row"><div class="rowhead"><span class="name">' + p.get('name', p['id']) + '</span>'
1115
+ '<span class="chip">' + KINDS.get(p.get('kind', 'code'), p.get('kind')) + '</span>'
1116
+ '<span class="chip st">' + status_cn + '</span>'
1117
+ '<span class="time">' + rel_time(info['activity']) + '</span></div>'
1118
+ '<div class="next">' + nxt_action + '</div>'
1119
+ '<div class="meta">' + ' · '.join(bits) + '</div></div>')
1120
+ more = ''
1121
+ if len(projects) > limit:
1122
+ more = '<div class="more">……另有 ' + str(len(projects) - limit) + ' 个项目未展示</div>'
1123
+ height = 300 + len(infos) * 128 + 60
1124
+ css = (
1125
+ "*{box-sizing:border-box;margin:0;padding:0}"
1126
+ "body{width:1080px;min-height:100vh;background:radial-gradient(900px 420px at 12% -8%,#12303a 0%,transparent 60%),#0b0f14;"
1127
+ "color:#e6edf3;font-family:-apple-system,'PingFang SC','Helvetica Neue',Arial,sans-serif;padding:46px 54px}"
1128
+ ".hdr{display:flex;align-items:baseline;justify-content:space-between;border-bottom:1px solid #1f2a37;padding-bottom:18px}"
1129
+ "h1{font-size:40px;letter-spacing:1px}h1 span{color:#35d0ba}"
1130
+ ".sub{margin-top:14px;color:#8b98a5;font-size:20px;display:flex;gap:18px;flex-wrap:wrap}"
1131
+ ".sub b{color:#e6edf3;font-weight:600}"
1132
+ ".cards{margin-top:26px;display:flex;flex-direction:column;gap:16px}"
1133
+ ".row{background:#121821;border:1px solid #1f2a37;border-radius:16px;padding:18px 22px;position:relative;overflow:hidden}"
1134
+ ".row::before{content:'';position:absolute;left:0;top:0;bottom:0;width:4px;background:#35d0ba}"
1135
+ ".rowhead{display:flex;align-items:center;gap:10px}"
1136
+ ".name{font-size:23px;font-weight:700}"
1137
+ ".chip{font-size:15px;color:#8b98a5;border:1px solid #24303d;border-radius:7px;padding:1px 9px;background:#0d131a}"
1138
+ ".chip.st{color:#7ce7da;border-color:#1f4d48;background:#0f1e1d}"
1139
+ ".time{margin-left:auto;font-size:16px;color:#6f7d8a}"
1140
+ ".next{margin-top:10px;font-size:18px;color:#cfd9e3;line-height:1.5}"
1141
+ ".meta{margin-top:8px;font-size:15px;color:#6f7d8a;font-family:ui-monospace,Menlo,monospace}"
1142
+ ".more{color:#6f7d8a;font-size:15px;margin-top:6px}"
1143
+ ".foot{margin-top:26px;border-top:1px solid #1f2a37;padding-top:16px;color:#6f7d8a;font-size:15px;display:flex;justify-content:space-between;gap:20px}"
1144
+ ".scan{color:#7ce7da}"
1145
+ )
1146
+ foot_time = ('下一条播报 ' + datetime.fromtimestamp(nxt).strftime('%H:%M')) if nxt else ''
1147
+ return ('<!DOCTYPE html><html><head><meta charset="utf-8"><style>' + css + '</style></head><body>'
1148
+ '<div class="hdr"><h1>项目播报 <span>·</span> 项目甲板</h1>'
1149
+ '<div class="time">' + datetime.now().strftime('%Y-%m-%d %H:%M') + '</div></div>'
1150
+ '<div class="sub"><span><b>' + str(len(projects)) + '</b> 个项目</span>'
1151
+ '<span>活跃 <b>' + str(active) + '</b></span><span>阻塞 <b>' + str(blocked) + '</b></span>'
1152
+ '<span>近 6 小时更新 <b>' + str(recent) + '</b></span></div>'
1153
+ '<div class="cards">' + ''.join(rows) + more + '</div>'
1154
+ '<div class="foot"><span class="scan">' + (scan_line or '自动发现:无新项目') + '</span>'
1155
+ '<span>' + foot_time + '</span></div>'
1156
+ '<script>addEventListener("load",()=>{document.title="H"+document.body.scrollHeight});</script>'
1157
+ '</body></html>')
1158
+
1159
+
1160
+ def render_image(cfg=None, out=None):
1161
+ cfg = cfg or load_json(CONFIG, {})
1162
+ os.makedirs(GRAPHICS, exist_ok=True)
1163
+ html = dashboard_html(cfg)
1164
+ ts = datetime.now().strftime('%Y%m%d-%H%M%S')
1165
+ html_path = os.path.join(GRAPHICS, 'board-' + ts + '.html')
1166
+ png_path = out or os.path.join(GRAPHICS, 'board-' + ts + '.png')
1167
+ with open(html_path, 'w', encoding='utf-8') as f:
1168
+ f.write(html)
1169
+ height = 900
1170
+ try:
1171
+ r = subprocess.run([CHROME, '--headless=new', '--disable-gpu',
1172
+ '--virtual-time-budget=1500', '--dump-dom', 'file://' + html_path],
1173
+ capture_output=True, timeout=60, text=True)
1174
+ m = re.search(r'<title>H(\d+)</title>', r.stdout or '')
1175
+ if m:
1176
+ height = int(m.group(1)) + 2
1177
+ except Exception:
1178
+ pass
1179
+ subprocess.run([CHROME, '--headless=new', '--disable-gpu', '--hide-scrollbars',
1180
+ '--force-device-scale-factor=1', '--window-size=1080,' + str(height),
1181
+ '--screenshot=' + png_path, 'file://' + html_path],
1182
+ capture_output=True, timeout=60)
1183
+ if not os.path.exists(png_path):
1184
+ raise RuntimeError('截图失败(chrome headless)')
1185
+ for f in sorted(glob.glob(os.path.join(GRAPHICS, 'board-*.png')))[:-20]:
1186
+ try:
1187
+ os.remove(f)
1188
+ except OSError:
1189
+ pass
1190
+ return png_path
1191
+
1192
+
1193
+ def cmd_image(argv):
1194
+ cfg = load_json(CONFIG, {})
1195
+ rep = cfg.setdefault('report', {})
1196
+ if '--on' in argv or '--off' in argv:
1197
+ rep['image'] = '--on' in argv
1198
+ save_json(CONFIG, cfg)
1199
+ print('周期播报附图:' + ('开启' if rep['image'] else '关闭'))
1200
+ return 0
1201
+ try:
1202
+ png = render_image(cfg)
1203
+ except Exception as e:
1204
+ print('生成图片失败:', e)
1205
+ return 1
1206
+ print('图片:', png)
1207
+ if '--no-send' in argv:
1208
+ return 0
1209
+ reg = load_json(REGISTRY, {'projects': []})
1210
+ n = len([p for p in reg.get('projects', []) if p.get('status') != 'archived'])
1211
+ caption = f'项目播报 · {n} 个项目 · {datetime.now():%m-%d %H:%M}'
1212
+ code, msg = send_wechat_image(png, caption)
1213
+ if code != 0:
1214
+ queue_image(png, caption)
1215
+ print(f'[wechat exit {code}] {msg}')
1216
+ return code
1217
+
1218
+
1219
+ # ── 命令 ────────────────────────────────────────────────────
1220
+ def cmd_list():
1221
+ reg = load_json(REGISTRY, {'projects': []})
1222
+ if not reg['projects']:
1223
+ print('(空)用 deck add 添加项目')
1224
+ return 0
1225
+ print(f'{"id":18} {"名称":14} {"类型":6} {"状态":8} {"优先级":6} 根目录')
1226
+ for p in sorted(reg['projects'], key=lambda x: -x.get('priority', 50)):
1227
+ print(f'{p["id"]:18} {p.get("name", "")[:12]:14} {p.get("kind","code"):6} '
1228
+ f'{p.get("status","active"):8} {p.get("priority",50):<6} {p.get("root","")}')
1229
+ return 0
1230
+
1231
+
1232
+ def cmd_add(argv):
1233
+ if not argv:
1234
+ print('用法: deck add <id> --root <path> [--name 名称] [--kind code] [--note 说明]')
1235
+ return 1
1236
+ pid = argv[0]
1237
+ opts = {}
1238
+ i = 1
1239
+ while i < len(argv):
1240
+ k = argv[i]
1241
+ if k.startswith('--') and i + 1 < len(argv):
1242
+ opts[k[2:]] = argv[i + 1]
1243
+ i += 2
1244
+ else:
1245
+ i += 1
1246
+ if 'root' not in opts:
1247
+ print('缺少 --root')
1248
+ return 1
1249
+ root = os.path.realpath(os.path.expanduser(opts['root']))
1250
+ if not os.path.isdir(root):
1251
+ print(f'目录不存在: {root}')
1252
+ return 1
1253
+ reg = load_json(REGISTRY, {'projects': []})
1254
+ for p in reg['projects']:
1255
+ if p['id'] == pid:
1256
+ print(f'已存在项目 {pid}')
1257
+ return 1
1258
+ if os.path.realpath(p.get('root', '')) == root:
1259
+ print(f'该目录已绑定项目 {p["id"]}')
1260
+ return 1
1261
+ reg['projects'].append({
1262
+ 'id': pid, 'name': opts.get('name', pid), 'aliases': [pid],
1263
+ 'kind': opts.get('kind', 'code'), 'status': 'active',
1264
+ 'priority': int(opts.get('priority', 50)), 'root': root,
1265
+ 'memory': None, 'note': opts.get('note', ''),
1266
+ 'created_at': datetime.now().astimezone().isoformat(timespec='seconds'),
1267
+ })
1268
+ save_json(REGISTRY, reg)
1269
+ print(f'已登记 {pid} → {root}')
1270
+ return 0
1271
+
1272
+
1273
+ def cmd_rm(argv):
1274
+ if not argv:
1275
+ return 1
1276
+ reg = load_json(REGISTRY, {'projects': []})
1277
+ before = len(reg['projects'])
1278
+ reg['projects'] = [p for p in reg['projects'] if p['id'] != argv[0]]
1279
+ if len(reg['projects']) == before:
1280
+ print(f'没有项目 {argv[0]}')
1281
+ return 1
1282
+ save_json(REGISTRY, reg)
1283
+ print(f'已移除 {argv[0]}(记忆与目录未动)')
1284
+ return 0
1285
+
1286
+
1287
+ def cmd_every(argv):
1288
+ if not argv:
1289
+ print(f'当前间隔 {load_json(CONFIG, {}).get("report", {}).get("intervalHours", 4)} 小时')
1290
+ return 0
1291
+ try:
1292
+ hours = float(argv[0])
1293
+ except ValueError:
1294
+ print('小时数无效')
1295
+ return 1
1296
+ if hours <= 0 or hours > 24 * 30:
1297
+ print('间隔需在 0 到 720 小时之间')
1298
+ return 1
1299
+ cfg = load_json(CONFIG, {})
1300
+ cfg.setdefault('report', {})['intervalHours'] = hours
1301
+ save_json(CONFIG, cfg)
1302
+ print(f'播报间隔已设为 {hours} 小时(守护进程 1 分钟内生效)')
1303
+ return 0
1304
+
1305
+
1306
+ def cmd_format(argv):
1307
+ cfg = load_json(CONFIG, {})
1308
+ rep = cfg.setdefault('report', {})
1309
+ cur = rep.get('format', 'markdown')
1310
+ if not argv:
1311
+ print(f'当前播报版式:{cur}(markdown | plain)')
1312
+ return 0
1313
+ v = argv[0].strip().lower()
1314
+ if v not in ('markdown', 'md', 'plain', 'text'):
1315
+ print('只支持 markdown 或 plain')
1316
+ return 1
1317
+ rep['format'] = 'markdown' if v in ('markdown', 'md') else 'plain'
1318
+ save_json(CONFIG, cfg)
1319
+ print(f'播报版式已设为 {rep["format"]}(下一次播报生效)')
1320
+ return 0
1321
+
1322
+
1323
+ def cmd_toggle(on):
1324
+ cfg = load_json(CONFIG, {})
1325
+ cfg.setdefault('report', {})['enabled'] = on
1326
+ save_json(CONFIG, cfg)
1327
+ print('周期播报:' + ('已开启' if on else '已关闭'))
1328
+ return 0
1329
+
1330
+
1331
+ def daemon_status():
1332
+ pid = None
1333
+ if os.path.exists(LOCK):
1334
+ try:
1335
+ with open(LOCK) as f:
1336
+ pid = int(f.read().strip() or 0)
1337
+ os.kill(pid, 0)
1338
+ except (ValueError, ProcessLookupError, PermissionError):
1339
+ pid = None
1340
+ return pid
1341
+
1342
+
1343
+ def cmd_doctor():
1344
+ reg = load_json(REGISTRY, {'projects': []})
1345
+ problems, warnings = [], []
1346
+ seen = {}
1347
+ for p in reg.get('projects', []):
1348
+ pid, root = p['id'], p.get('root', '')
1349
+ if root in seen:
1350
+ problems.append(f'{pid}: 根目录与 {seen[root]} 重复绑定({root})')
1351
+ seen[root] = pid
1352
+ if not os.path.isdir(root):
1353
+ problems.append(f'{pid}: 目录不存在 {root}')
1354
+ continue
1355
+ if p.get('memory') and not state_file(p):
1356
+ problems.append(f'{pid}: 记忆缺失(STATE 不存在)')
1357
+ if not os.path.isdir(os.path.join(root, '.dsh-graph')):
1358
+ warnings.append(f'{pid}: 无执行看板(.dsh-graph 缺失)')
1359
+ if not p.get('memory'):
1360
+ warnings.append(f'{pid}: 未落盘(无 ~/.ai STATE)')
1361
+ print(f'项目 {len(reg.get("projects", []))} 个,问题 {len(problems)},提示 {len(warnings)}')
1362
+ for x in problems:
1363
+ print(' ✗ ' + x)
1364
+ for x in warnings:
1365
+ print(' · ' + x)
1366
+ return 0 if not problems else 1
1367
+
1368
+
1369
+ def cmd_status():
1370
+ cfg = load_json(CONFIG, {})
1371
+ st = load_json(STATE, {})
1372
+ r = cfg.get('report', {})
1373
+ pid = daemon_status()
1374
+ print(f"配置: {CONFIG}")
1375
+ print(f"注册表: {REGISTRY}({len(load_json(REGISTRY, {'projects': []})['projects'])} 个项目)")
1376
+ print(f"周期播报: {'开启' if r.get('enabled', True) else '关闭'},每 {r.get('intervalHours', 4)} 小时,"
1377
+ f"版式 {r.get('format', 'markdown')},{'附图' if r.get('image') else '不附图'}")
1378
+ print(f"守护进程: {'运行中 pid ' + str(pid) if pid else '未运行'}")
1379
+ sc = load_json(SCAN_STATE, {})
1380
+ if sc.get('lastRun'):
1381
+ print(f"自动发现: 上次扫描 {datetime.fromtimestamp(sc['lastRun']):%m-%d %H:%M}"
1382
+ f"({rel_time(sc['lastRun'])}),候选 {len(sc.get('candidates', []))} 个")
1383
+ else:
1384
+ print("自动发现: 尚未扫描")
1385
+ q = (cfg.get('schedule') or {}).get('quiet') or {}
1386
+ if q.get('from'):
1387
+ print(f"安静时段: {q['from']} – {q['to']}(期间不播报、不扫描)")
1388
+ last = st.get('last_run')
1389
+ print(f"上次播报: {datetime.fromtimestamp(last):%Y-%m-%d %H:%M}({rel_time(last)})" if last else '上次播报: 从未')
1390
+ nxt = (last or time.time()) + r.get('intervalHours', 4) * 3600
1391
+ while in_quiet(cfg, nxt):
1392
+ nxt += 1800
1393
+ print(f"下次播报: {datetime.fromtimestamp(nxt):%Y-%m-%d %H:%M}")
1394
+ wx = subprocess.run([WXSEND, '--status'], capture_output=True, text=True)
1395
+ print('微信通道: ' + ('✅ 可发送' if wx.returncode == 0 else '⚠️ 未接通(播报会排队,授权后补发)'))
1396
+ return 0 if pid else 1
1397
+
1398
+
1399
+ def cmd_daemon():
1400
+ os.makedirs(os.path.dirname(LOCK), exist_ok=True)
1401
+ fh = open(LOCK, 'w')
1402
+ try:
1403
+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
1404
+ except OSError:
1405
+ log('已有守护进程在跑,退出')
1406
+ return 0
1407
+ fh.write(str(os.getpid()))
1408
+ fh.flush()
1409
+ log(f'守护进程启动 pid={os.getpid()}')
1410
+ while True:
1411
+ try:
1412
+ cfg = load_json(CONFIG, {})
1413
+ r = cfg.get('report', {})
1414
+ if in_quiet(cfg):
1415
+ time.sleep(60)
1416
+ continue
1417
+ try:
1418
+ flush_pending_image()
1419
+ except Exception as e:
1420
+ log(f'[image] 补发异常: {e!r}')
1421
+ # 自动发现:按 scan.intervalHours 周期扫描
1422
+ sc = cfg.get('scan', {})
1423
+ if sc.get('enabled', True):
1424
+ sst = load_json(SCAN_STATE, {})
1425
+ sinterval = float(sc.get('intervalHours', 24)) * 3600
1426
+ if time.time() - float(sst.get('lastRun', 0)) >= sinterval:
1427
+ try:
1428
+ auto_scan(cfg)
1429
+ except Exception as e:
1430
+ log(f'[scan] 异常: {e!r}')
1431
+ if r.get('enabled', True):
1432
+ st = load_json(STATE, {})
1433
+ last = st.get('last_run', 0)
1434
+ interval = float(r.get('intervalHours', 4)) * 3600
1435
+ if time.time() - last >= interval:
1436
+ do_report_with_image(cfg, quiet=True)
1437
+ st['last_run'] = time.time()
1438
+ save_json(STATE, st)
1439
+ except Exception as e: # 守护进程不能因单次失败退出
1440
+ log(f'循环异常: {e!r}')
1441
+ time.sleep(30)
1442
+
1443
+
1444
+ def cmd_demo(argv):
1445
+ """deck demo:生成一个演示环境(假项目 + 独立面板端口),不碰真实注册表。"""
1446
+ import shutil
1447
+ home = os.path.expanduser('~/.project-deck-demo')
1448
+ port = '9299'
1449
+ for i, a in enumerate(argv):
1450
+ if a == '--port' and i + 1 < len(argv):
1451
+ port = argv[i + 1]
1452
+ if os.path.isdir(home):
1453
+ shutil.rmtree(home)
1454
+ os.makedirs(home, exist_ok=True)
1455
+ projects = [
1456
+ ('website', '官网改版', 'content', '落地页文案与设计,本周出第一版'),
1457
+ ('api-server', 'API 服务', 'code', '把 v1 接口迁到 v2,补 12 个测试'),
1458
+ ('notes-app', '笔记应用', 'code', '离线同步还有 2 个 bug'),
1459
+ ('infra', '基础设施', 'ops', '备份脚本换成 restic,验证一次恢复'),
1460
+ ]
1461
+ reg = {'version': 1, 'projects': []}
1462
+ for pid, name, kind, note in projects:
1463
+ root = os.path.join(home, 'projects', pid)
1464
+ os.makedirs(root, exist_ok=True)
1465
+ open(os.path.join(root, 'README.md'), 'w').write(f'# {name}\n\n{note}\n')
1466
+ if kind == 'code':
1467
+ open(os.path.join(root, 'package.json'), 'w').write('{"name":"%s","version":"0.1.0"}\n' % pid)
1468
+ subprocess.run(['git', 'init', '-q'], cwd=root)
1469
+ subprocess.run(['git', '-c', 'user.name=demo', '-c', 'user.email=demo@example.com',
1470
+ 'commit', '-qm', f'init: {name}', '--allow-empty'], cwd=root)
1471
+ reg['projects'].append({'id': pid, 'name': name, 'aliases': [pid], 'kind': kind,
1472
+ 'status': 'active', 'priority': 60, 'root': root,
1473
+ 'memory': None, 'note': note,
1474
+ 'created_at': time.strftime('%Y-%m-%dT%H:%M:%S%z')})
1475
+ cfg = {'version': 1,
1476
+ 'report': {'enabled': False, 'intervalHours': 4, 'format': 'markdown', 'image': False},
1477
+ 'scan': {'enabled': False, 'intervalHours': 12},
1478
+ 'panel': {'allowLanRead': False}}
1479
+ save_json(os.path.join(home, 'registry.json'), reg)
1480
+ save_json(os.path.join(home, 'config.json'), cfg)
1481
+ os.makedirs(os.path.join(home, 'graphics'), exist_ok=True)
1482
+ print(f'演示环境: {home}')
1483
+ print(f'面板地址: http://127.0.0.1:{port} (Ctrl-C 结束)')
1484
+ env = dict(os.environ, DECK_HOME=home, DECK_PANEL_PORT=str(port), DECK_PANEL_HOST='127.0.0.1')
1485
+ try:
1486
+ subprocess.run([sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'panel.py')], env=env)
1487
+ except KeyboardInterrupt:
1488
+ pass
1489
+ return 0
1490
+
1491
+
1492
+ def main():
1493
+ args = sys.argv[1:]
1494
+ if not args:
1495
+ return cmd_status()
1496
+ cmd, rest = args[0], args[1:]
1497
+ if cmd in ('list', 'ls'):
1498
+ return cmd_list()
1499
+ if cmd == 'add':
1500
+ return cmd_add(rest)
1501
+ if cmd == 'rm':
1502
+ return cmd_rm(rest)
1503
+ if cmd == 'every':
1504
+ return cmd_every(rest)
1505
+ if cmd == 'format':
1506
+ return cmd_format(rest)
1507
+ if cmd == 'scan':
1508
+ return cmd_scan(rest)
1509
+ if cmd == 'nodes':
1510
+ return cmd_nodes(rest)
1511
+ if cmd == 'image':
1512
+ return cmd_image(rest)
1513
+ if cmd == 'on':
1514
+ return cmd_toggle(True)
1515
+ if cmd == 'off':
1516
+ return cmd_toggle(False)
1517
+ if cmd == 'status':
1518
+ return cmd_status()
1519
+ if cmd == 'doctor':
1520
+ return cmd_doctor()
1521
+ if cmd == 'preview':
1522
+ print(build_report(load_json(CONFIG, {})))
1523
+ return 0
1524
+ if cmd == 'report':
1525
+ cfg0 = load_json(CONFIG, {})
1526
+ force = False if '--no-image' in rest else (True if '--image' in rest else None)
1527
+ code, text, msg = do_report_with_image(cfg0, force=force)
1528
+ st = load_json(STATE, {})
1529
+ st['last_run'] = time.time()
1530
+ save_json(STATE, st)
1531
+ return code
1532
+ if cmd == 'demo':
1533
+ return cmd_demo(rest)
1534
+ if cmd == 'panel':
1535
+ port = os.environ.get('DECK_PANEL_PORT', '9199')
1536
+ url = f'http://127.0.0.1:{port}'
1537
+ print(f'管理面板: {url}')
1538
+ try:
1539
+ subprocess.run(['open', url], timeout=5)
1540
+ except (OSError, subprocess.SubprocessError):
1541
+ pass
1542
+ return 0
1543
+ if cmd == 'daemon':
1544
+ return cmd_daemon()
1545
+ print(__doc__)
1546
+ return 1
1547
+
1548
+
1549
+ if __name__ == '__main__':
1550
+ sys.exit(main())