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.
- package/LICENSE +21 -0
- package/README.md +111 -2
- package/backend/__pycache__/panel.cpython-314.pyc +0 -0
- package/backend/deck +1765 -0
- package/backend/deck-node +188 -0
- package/backend/deck-node.js +152 -0
- package/backend/deck-node.ps1 +106 -0
- package/backend/panel.html +855 -0
- package/backend/panel.py +953 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +366 -0
- package/lib/index.js +98 -0
- package/package.json +56 -4
|
@@ -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
|