dsh-project-deck 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env python3
2
+ """deck-node —— 多节点扫描器(在每台机器上运行,把本地项目清单推给协调端)。
3
+
4
+ deck-node scan [--roots ~/Desktop,~] [--depth 1] [--json]
5
+ deck-node push --server http://192.168.1.3:9199 --token XXX --id hos
6
+ deck-node loop --interval 21600 # 每 6 小时自动 push 一次
7
+
8
+ 配置也可写在 ~/.deck-node.json:
9
+ {"server":"http://192.168.1.3:9199","token":"...","id":"hos","roots":["~/"],"depth":1}
10
+
11
+ 只读:不会修改任何项目文件;只上报目录名、标记文件、git 最近提交与时间。
12
+ 纯标准库,macOS / Linux / Android(Termux) / Windows 均可运行。
13
+ """
14
+ import argparse
15
+ import json
16
+ import os
17
+ import platform
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ import urllib.request
22
+
23
+ HOME = os.path.expanduser('~')
24
+ CONF = os.path.join(HOME, '.deck-node.json')
25
+ MARKERS = ['.git', 'package.json', 'pnpm-workspace.yaml', 'pyproject.toml', 'setup.py',
26
+ 'go.mod', 'Cargo.toml', 'AGENTS.md', 'CLAUDE.md', '.dsh-graph', 'README.md']
27
+ SKIP = {'.git', 'node_modules', '.venv', 'venv', '__pycache__', 'Library', 'AppData',
28
+ 'Downloads', '.npm', '.cache', '.Trash', 'dist', 'build', 'target', 'go', 'vendor'}
29
+ MAX_PROJECTS = 400
30
+
31
+
32
+ def load_conf():
33
+ try:
34
+ return json.load(open(CONF))
35
+ except (FileNotFoundError, json.JSONDecodeError):
36
+ return {}
37
+
38
+
39
+ def markers_of(path):
40
+ found, latest = [], 0
41
+ for m in MARKERS:
42
+ p = os.path.join(path, m)
43
+ if os.path.exists(p):
44
+ found.append(m)
45
+ if m != '.git':
46
+ try:
47
+ latest = max(latest, os.path.getmtime(p))
48
+ except OSError:
49
+ pass
50
+ return found, latest
51
+
52
+
53
+ def git_info(path):
54
+ try:
55
+ r = subprocess.run(['git', '-C', path, 'log', '-1', '--format=%ct%x1f%h%x1f%s'],
56
+ capture_output=True, text=True, timeout=5)
57
+ if r.returncode != 0 or not r.stdout.strip():
58
+ return None
59
+ ts, sha, subj = r.stdout.strip().split('\x1f', 2)
60
+ dirty = subprocess.run(['git', '-C', path, 'status', '--porcelain'],
61
+ capture_output=True, text=True, timeout=5).stdout
62
+ return {'ts': int(ts), 'sha': sha, 'subject': subj[:80],
63
+ 'dirty': len([l for l in dirty.splitlines() if l.strip()])}
64
+ except (OSError, subprocess.SubprocessError, ValueError):
65
+ return None
66
+
67
+
68
+ def scan(roots, depth):
69
+ projects, seen = [], set()
70
+ for root in roots:
71
+ base = os.path.realpath(os.path.expanduser(root))
72
+ if not os.path.isdir(base):
73
+ continue
74
+ level = [(base, 0)]
75
+ while level:
76
+ cur, d = level.pop()
77
+ if d >= depth:
78
+ continue
79
+ try:
80
+ entries = sorted(os.scandir(cur), key=lambda e: e.name)
81
+ except OSError:
82
+ continue
83
+ for e in entries:
84
+ if not e.is_dir(follow_symlinks=False) or e.name.startswith('.') or e.name in SKIP:
85
+ continue
86
+ sub = os.path.realpath(e.path)
87
+ if sub in seen:
88
+ continue
89
+ mk, latest = markers_of(sub)
90
+ if mk:
91
+ seen.add(sub)
92
+ git = git_info(sub)
93
+ projects.append({
94
+ 'path': sub, 'name': os.path.basename(sub), 'markers': mk,
95
+ 'git': git, 'last': latest or (git['ts'] if git else 0),
96
+ })
97
+ level.append((sub, d + 1))
98
+ if len(projects) >= MAX_PROJECTS:
99
+ return projects
100
+ return projects
101
+
102
+
103
+ def push(servers, token, node_id, roots, depth):
104
+ data = {
105
+ 'node': node_id, 'token': token,
106
+ 'platform': platform.system().lower() + ' ' + platform.release(),
107
+ 'scanned_at': time.time(),
108
+ 'projects': scan(roots, depth),
109
+ }
110
+ body = json.dumps(data, ensure_ascii=False).encode()
111
+ last_err = None
112
+ for server in servers:
113
+ try:
114
+ req = urllib.request.Request(server.rstrip('/') + '/api/node-scan', data=body,
115
+ headers={'Content-Type': 'application/json'})
116
+ with urllib.request.urlopen(req, timeout=30) as r:
117
+ res = json.loads(r.read().decode())
118
+ print(f"[deck-node] 上报 {len(data['projects'])} 个项目 → {server} ({res.get('ok')})")
119
+ return res
120
+ except Exception as e:
121
+ last_err = e
122
+ print(f'[deck-node] {server} 失败: {e}')
123
+ raise last_err
124
+
125
+
126
+ def in_quiet(conf, ts=None):
127
+ q = conf.get('quiet') or {}
128
+ start, end = q.get('from'), q.get('to')
129
+ if not start or not end:
130
+ return False
131
+ hm = time.strftime('%H:%M', time.localtime(ts or time.time()))
132
+ if start <= end:
133
+ return start <= hm < end
134
+ return hm >= start or hm < end
135
+
136
+
137
+ def main():
138
+ conf = load_conf()
139
+ ap = argparse.ArgumentParser(description='deck-node 多节点扫描器')
140
+ ap.add_argument('action', choices=['scan', 'push', 'loop'], nargs='?', default='scan')
141
+ ap.add_argument('--server', default=conf.get('server', ''))
142
+ ap.add_argument('--token', default=conf.get('token', ''))
143
+ ap.add_argument('--id', default=conf.get('id', ''))
144
+ ap.add_argument('--roots', default=','.join(conf.get('roots', ['~/Desktop', '~'])))
145
+ ap.add_argument('--depth', type=int, default=int(conf.get('depth', 1)))
146
+ ap.add_argument('--interval', type=int, default=int(conf.get('interval', 21600)))
147
+ ap.add_argument('--json', action='store_true')
148
+ args = ap.parse_args()
149
+
150
+ roots = [r.strip() for r in args.roots.split(',') if r.strip()]
151
+ if args.action == 'scan':
152
+ out = scan(roots, args.depth)
153
+ if args.json:
154
+ print(json.dumps({'projects': out}, ensure_ascii=False, indent=2))
155
+ else:
156
+ for p in out:
157
+ print(f"{p['markers'][0]:14} {p['path']}")
158
+ print(f'共 {len(out)} 个项目')
159
+ return 0
160
+ if not (args.server and args.token and args.id):
161
+ print('需要 --server / --token / --id(或写进 ~/.deck-node.json)')
162
+ return 1
163
+ raw_servers = conf.get('servers') or args.server
164
+ if isinstance(raw_servers, str):
165
+ servers = [x.strip() for x in raw_servers.split(',') if x.strip()]
166
+ else:
167
+ servers = [str(x).strip() for x in raw_servers if str(x).strip()]
168
+ if args.action == 'push':
169
+ push(servers, args.token, args.id, roots, args.depth)
170
+ return 0
171
+ # loop(安静时段内不推送)
172
+ quiet = conf.get('quiet') or {}
173
+ print(f"[deck-node] 每 {args.interval}s 上报一次"
174
+ + (f";安静时段 {quiet.get('from')}–{quiet.get('to')}" if quiet.get('from') else '')
175
+ + ',Ctrl-C 结束')
176
+ while True:
177
+ if in_quiet(conf):
178
+ time.sleep(60)
179
+ continue
180
+ try:
181
+ push(servers, args.token, args.id, roots, args.depth)
182
+ except Exception as e:
183
+ print(f'[deck-node] 上报失败: {e}')
184
+ time.sleep(args.interval)
185
+
186
+
187
+ if __name__ == '__main__':
188
+ sys.exit(main())
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deck-node (Node 版) —— 多节点扫描器,功能与 deck-node(Python) 一致。
4
+ *
5
+ * node deck-node.js scan [--roots ~/Desktop,~] [--depth 1]
6
+ * node deck-node.js push --server http://192.168.1.3:9199 --token XXX --id hos
7
+ * node deck-node.js loop --interval 21600
8
+ *
9
+ * 配置也可写 ~/.deck-node.json:{"server":...,"token":...,"id":...,"roots":[...],"depth":1}
10
+ * 只读:不修改项目文件,只上报目录名 / 标记 / git 最近提交。
11
+ */
12
+ 'use strict';
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const http = require('http');
17
+ const https = require('https');
18
+ const { execSync } = require('child_process');
19
+
20
+ const HOME = os.homedir();
21
+ const CONF = path.join(HOME, '.deck-node.json');
22
+ const MARKERS = ['.git', 'package.json', 'pnpm-workspace.yaml', 'pyproject.toml', 'setup.py',
23
+ 'go.mod', 'Cargo.toml', 'AGENTS.md', 'CLAUDE.md', '.dsh-graph', 'README.md'];
24
+ const SKIP = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', 'Library',
25
+ 'AppData', 'Downloads', '.npm', '.cache', '.Trash', 'dist', 'build', 'target', 'go', 'vendor']);
26
+ const MAX_PROJECTS = 400;
27
+
28
+ function loadConf() {
29
+ try { return JSON.parse(fs.readFileSync(CONF, 'utf8')); } catch { return {}; }
30
+ }
31
+
32
+ function markersOf(dir) {
33
+ const found = [];
34
+ let latest = 0;
35
+ for (const m of MARKERS) {
36
+ const p = path.join(dir, m);
37
+ if (!fs.existsSync(p)) continue;
38
+ found.push(m);
39
+ if (m === '.git') continue;
40
+ try { latest = Math.max(latest, fs.statSync(p).mtimeMs / 1000); } catch {}
41
+ }
42
+ return { found, latest };
43
+ }
44
+
45
+ function gitInfo(dir) {
46
+ try {
47
+ const out = execSync(`git -C ${JSON.stringify(dir)} log -1 --format='%ct|%h|%s'`,
48
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim();
49
+ if (!out) return null;
50
+ const [ts, sha, subj] = out.split('|');
51
+ const dirty = execSync(`git -C ${JSON.stringify(dir)} status --porcelain`,
52
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 })
53
+ .split('\n').filter(l => l.trim()).length;
54
+ return { ts: parseInt(ts, 10), sha, subject: (subj || '').slice(0, 80), dirty };
55
+ } catch { return null; }
56
+ }
57
+
58
+ function scan(roots, depth) {
59
+ const projects = [];
60
+ const seen = new Set();
61
+ for (const root of roots) {
62
+ const base = path.resolve(root.replace(/^~(?=$|\/)/, HOME));
63
+ if (!fs.existsSync(base) || !fs.statSync(base).isDirectory()) continue;
64
+ const level = [[base, 0]];
65
+ while (level.length) {
66
+ const [cur, d] = level.pop();
67
+ if (d >= depth) continue;
68
+ let entries;
69
+ try { entries = fs.readdirSync(cur, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); } catch { continue; }
70
+ for (const e of entries) {
71
+ if (!e.isDirectory() || e.name.startsWith('.') || SKIP.has(e.name)) continue;
72
+ const sub = fs.realpathSync(path.join(cur, e.name));
73
+ if (seen.has(sub)) continue;
74
+ const { found, latest } = markersOf(sub);
75
+ if (found.length) {
76
+ seen.add(sub);
77
+ const git = gitInfo(sub);
78
+ projects.push({ path: sub, name: path.basename(sub), markers: found, git,
79
+ last: latest || (git ? git.ts : 0) });
80
+ }
81
+ level.push([sub, d + 1]);
82
+ if (projects.length >= MAX_PROJECTS) return projects;
83
+ }
84
+ }
85
+ }
86
+ return projects;
87
+ }
88
+
89
+ function post(server, payload) {
90
+ const url = new URL(server.replace(/\/+$/, '') + '/api/node-scan');
91
+ const body = Buffer.from(JSON.stringify(payload));
92
+ const lib = url.protocol === 'https:' ? https : http;
93
+ return new Promise((resolve, reject) => {
94
+ const req = lib.request({ hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80),
95
+ path: url.pathname, method: 'POST', headers: { 'content-type': 'application/json', 'content-length': body.length } },
96
+ res => {
97
+ let data = '';
98
+ res.on('data', c => (data += c));
99
+ res.on('end', () => resolve({ status: res.statusCode, body: data }));
100
+ });
101
+ req.on('error', reject);
102
+ req.setTimeout(60000, () => req.destroy(new Error('timeout')));
103
+ req.write(body);
104
+ req.end();
105
+ });
106
+ }
107
+
108
+ async function push(conf, roots, depth) {
109
+ const payload = {
110
+ node: conf.id, token: conf.token, platform: `${process.platform} ${os.release()}`,
111
+ scanned_at: Date.now() / 1000, projects: scan(roots, depth),
112
+ };
113
+ const r = await post(conf.server, payload);
114
+ console.log(`[deck-node] 上报 ${payload.projects.length} 个项目 → ${conf.server} (HTTP ${r.status}) ${r.body.slice(0, 120)}`);
115
+ }
116
+
117
+ function parseArgs(argv) {
118
+ const out = { _: [] };
119
+ for (let i = 0; i < argv.length; i++) {
120
+ const a = argv[i];
121
+ if (a.startsWith('--')) { out[a.slice(2)] = argv[++i]; } else out._.push(a);
122
+ }
123
+ return out;
124
+ }
125
+
126
+ (async () => {
127
+ const conf = Object.assign(loadConf(), parseArgs(process.argv.slice(2)));
128
+ const action = conf._[0] || 'scan';
129
+ const roots = (Array.isArray(conf.roots) ? conf.roots : String(conf.roots || '~/Desktop,~').split(','))
130
+ .map(s => String(s).trim()).filter(Boolean);
131
+ const depth = parseInt(conf.depth || 1, 10);
132
+
133
+ if (action === 'scan') {
134
+ const projects = scan(roots, depth);
135
+ if (process.argv.includes('--json')) console.log(JSON.stringify({ projects }, null, 2));
136
+ else { projects.forEach(p => console.log(`${p.markers[0]}\t${p.path}`)); console.log(`共 ${projects.length} 个项目`); }
137
+ return;
138
+ }
139
+ if (!conf.server || !conf.token || !conf.id) {
140
+ console.error('需要 --server / --token / --id(或写进 ~/.deck-node.json)');
141
+ process.exit(1);
142
+ }
143
+ if (action === 'push') { await push(conf, roots, depth); return; }
144
+ if (action === 'loop') {
145
+ const interval = parseInt(conf.interval || 21600, 10) * 1000;
146
+ console.log(`[deck-node] 每 ${interval / 1000}s 上报一次`);
147
+ for (;;) {
148
+ try { await push(conf, roots, depth); } catch (e) { console.log('[deck-node] 上报失败:', e.message); }
149
+ await new Promise(r => setTimeout(r, interval));
150
+ }
151
+ }
152
+ })();
@@ -0,0 +1,106 @@
1
+ # deck-node.ps1 —— Windows 版项目扫描器(只读,上报给 deck 协调端)
2
+ # 用法:
3
+ # powershell -ExecutionPolicy Bypass -File deck-node.ps1 -Server http://192.168.1.3:9199 -Token XXX -Id wencha-win
4
+ # powershell -ExecutionPolicy Bypass -File deck-node.ps1 -ScanOnly
5
+ # 配置也可放 $HOME\.deck-node.json:{"server":..,"token":..,"id":..,"roots":[..],"depth":1}
6
+
7
+ param(
8
+ [string]$Server = '',
9
+ [string]$Token = '',
10
+ [string]$Id = '',
11
+ [string[]]$Roots = @("$HOME"),
12
+ [int]$Depth = 1,
13
+ [switch]$ScanOnly,
14
+ [switch]$Loop,
15
+ [int]$Interval = 21600
16
+ )
17
+
18
+ $ErrorActionPreference = 'Continue'
19
+ $Markers = @('.git','package.json','pyproject.toml','go.mod','Cargo.toml','AGENTS.md','CLAUDE.md','README.md')
20
+ $Skip = @('node_modules','.venv','venv','__pycache__','AppData','.npm','.cache','dist','build','target','vendor','.git')
21
+
22
+ # 读配置
23
+ $conf = @{}
24
+ $confPath = Join-Path $HOME '.deck-node.json'
25
+ if (Test-Path $confPath) {
26
+ try { $conf = Get-Content $confPath -Raw | ConvertFrom-Json } catch {}
27
+ }
28
+ if (-not $Server -and $conf.server) { $Server = $conf.server }
29
+ if (-not $Token -and $conf.token) { $Token = $conf.token }
30
+ if (-not $Id -and $conf.id) { $Id = $conf.id }
31
+ if ($conf.roots -and $PSBoundParameters.ContainsKey('Roots') -eq $false) { $Roots = $conf.roots }
32
+ if ($conf.depth) { $Depth = [int]$conf.depth }
33
+
34
+ function Get-GitInfo($path) {
35
+ try {
36
+ $out = & git -C $path log -1 --format='%ct|%h|%s' 2>$null
37
+ if (-not $out) { return $null }
38
+ $parts = "$out".Split('|', 3)
39
+ $dirty = (& git -C $path status --porcelain 2>$null | Measure-Object).Count
40
+ return @{ ts = [int]$parts[0]; sha = $parts[1]; subject = $parts[2]; dirty = $dirty }
41
+ } catch { return $null }
42
+ }
43
+
44
+ function Scan-Projects($roots, $depth) {
45
+ $projects = @()
46
+ $seen = @{}
47
+ foreach ($root in $roots) {
48
+ $base = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($root)
49
+ if (-not (Test-Path $base)) { continue }
50
+ $level = @(, @($base, 0))
51
+ while ($level.Count -gt 0) {
52
+ $cur, $d = $level[-1]; $level = $level[0..($level.Count-2)]
53
+ if ($d -ge $depth) { continue }
54
+ $dirs = Get-ChildItem -LiteralPath $cur -Directory -ErrorAction SilentlyContinue
55
+ foreach ($dir in $dirs) {
56
+ if ($dir.Name.StartsWith('.') -or $Skip -contains $dir.Name) { continue }
57
+ $sub = $dir.FullName
58
+ if ($seen.ContainsKey($sub)) { continue }
59
+ $mk = @(); $latest = [datetime]::MinValue
60
+ foreach ($m in $Markers) {
61
+ $p = Join-Path $sub $m
62
+ if (Test-Path $p) {
63
+ $mk += $m
64
+ if ($m -ne '.git') {
65
+ $t = (Get-Item $p).LastWriteTime
66
+ if ($t -gt $latest) { $latest = $t }
67
+ }
68
+ }
69
+ }
70
+ if ($mk.Count -gt 0) {
71
+ $seen[$sub] = $true
72
+ $git = Get-GitInfo $sub
73
+ $ts = if ($latest -eq [datetime]::MinValue) { 0 } else { [int][double]::Parse((Get-Date $latest -UFormat %s)) }
74
+ $projects += [pscustomobject]@{
75
+ path = $sub; name = (Split-Path $sub -Leaf); markers = $mk; git = $git; last = $ts
76
+ }
77
+ }
78
+ $level += ,@($sub, ($d + 1))
79
+ if ($projects.Count -ge 400) { return $projects }
80
+ }
81
+ }
82
+ }
83
+ return $projects
84
+ }
85
+
86
+ function Push-Scan() {
87
+ if (-not $Server -or -not $Token -or -not $Id) { Write-Error '需要 -Server / -Token / -Id'; return }
88
+ $projects = Scan-Projects $Roots $Depth
89
+ $payload = @{
90
+ node = $Id; token = $Token; platform = "win32 $([System.Environment]::OSVersion.Version)"
91
+ scanned_at = [int][double]::Parse((Get-Date -UFormat %s)); projects = $projects
92
+ } | ConvertTo-Json -Depth 8 -Compress
93
+ try {
94
+ $r = Invoke-RestMethod -Uri "$($Server.TrimEnd('/'))/api/node-scan" -Method Post -ContentType 'application/json; charset=utf-8' -Body ([System.Text.Encoding]::UTF8.GetBytes($payload))
95
+ Write-Host "[deck-node] 上报 $($projects.Count) 个项目 -> $Server ($($r.ok))"
96
+ } catch { Write-Host "[deck-node] 上报失败: $($_.Exception.Message)" }
97
+ }
98
+
99
+ if ($ScanOnly) {
100
+ Scan-Projects $Roots $Depth | ConvertTo-Json -Depth 6
101
+ exit 0
102
+ }
103
+ if ($Loop) {
104
+ while ($true) { Push-Scan; Start-Sleep -Seconds $Interval }
105
+ }
106
+ Push-Scan