@tomjerrygit/dsh-workspace-audit 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +38 -2
- package/client.js +218 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +3 -0
- package/index.js +574 -0
- package/locale/en.json +3 -0
- package/locale/zh.json +3 -0
- package/package.json +65 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TomJerryGit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tomjerrygit/dsh-workspace-audit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Local-first workspace health audit for DeepSeek Harness. Read-only by design; no code is uploaded and no LLM call is required.
|
|
4
|
+
|
|
5
|
+
## What it checks
|
|
6
|
+
|
|
7
|
+
- Git: repository, remote, branch, dirty count, last commit, tags
|
|
8
|
+
- Project: README, LICENSE, .gitignore, manifests, lockfiles, tests, docs, CI
|
|
9
|
+
- Dependencies: manifests/lockfiles, unpinned requirements, `npm audit`/`pip-audit` when available
|
|
10
|
+
- GitHub: latest release vs local tag, open issues, archived state, branch protection (public repos)
|
|
11
|
+
- Size: source/scan size and known build/cache directories
|
|
12
|
+
- Security hotspots: self-hosted runner triggered by `pull_request`, suspected key filenames (names only), `shell=True`/`exec`/`pickle.load` patterns
|
|
13
|
+
- Risks: P0/P1/P2 list, risk filters, project comparison table, score trend history, and a Markdown handover report
|
|
14
|
+
|
|
15
|
+
## Tests
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
node --test test/audit.test.mjs
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The test suite uses a self-contained fixture workspace (`test/fixtures/basic`) and checks Git scanning, secret filename detection, workflow risk detection, dependency health, report rendering, GitHub remote parsing, and grading.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
dsh plugin --profile web add @tomjerrygit/dsh-workspace-audit
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Market index:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
https://tomjerrygit.github.io/dsh-workspace-audit/index.json
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
Open **Workspace Audit** in the left sidebar, add workspace paths if needed, and run the audit. The report can be copied or downloaded as Markdown.
|
|
38
|
+
|
|
39
|
+
> This is an engineering hygiene check, not a professional security audit. Suspected secret files are reported by filename only; contents are not read.
|
package/client.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: '@tomjerrygit/dsh-workspace-audit',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react');
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
const PANEL_ID = 'workspace-audit';
|
|
7
|
+
const NS = 'workspaceAudit';
|
|
8
|
+
const zh = {
|
|
9
|
+
panel: '工作区体检',
|
|
10
|
+
title: '工作区体检',
|
|
11
|
+
subtitle: '本地只读扫描:Git、测试、CI、体积、安全与交接报告',
|
|
12
|
+
scan: '开始体检',
|
|
13
|
+
scanning: '扫描中…',
|
|
14
|
+
refresh: '刷新',
|
|
15
|
+
addRoot: '添加工作区路径',
|
|
16
|
+
rootPlaceholder: '例如 D:\\MyProject',
|
|
17
|
+
add: '添加',
|
|
18
|
+
empty: '暂无工作区。先添加一个目录,或等待 DSH 工作区注册表出现。',
|
|
19
|
+
score: '综合评分',
|
|
20
|
+
workspaces: '工作区',
|
|
21
|
+
p0: 'P0 风险',
|
|
22
|
+
risks: '风险',
|
|
23
|
+
dimensions: '维度',
|
|
24
|
+
report: '交接报告',
|
|
25
|
+
copy: '复制报告',
|
|
26
|
+
copied: '已复制',
|
|
27
|
+
download: '下载 Markdown',
|
|
28
|
+
failed: '扫描失败',
|
|
29
|
+
loading: '正在准备…',
|
|
30
|
+
path: '路径',
|
|
31
|
+
git: 'Git',
|
|
32
|
+
size: '体积',
|
|
33
|
+
tests: '测试文件',
|
|
34
|
+
todo: 'TODO',
|
|
35
|
+
noRisk: '未发现阻塞风险',
|
|
36
|
+
commands: '启动/测试入口见各项目 README 与报告。',
|
|
37
|
+
filterAll: '全部风险',
|
|
38
|
+
comparison: '项目对比',
|
|
39
|
+
trend: '趋势',
|
|
40
|
+
deps: '依赖',
|
|
41
|
+
vulns: '漏洞',
|
|
42
|
+
github: 'GitHub',
|
|
43
|
+
release: 'Release',
|
|
44
|
+
branch: '分支保护',
|
|
45
|
+
noLock: '无锁文件',
|
|
46
|
+
};
|
|
47
|
+
const en = {
|
|
48
|
+
panel: 'Workspace Audit',
|
|
49
|
+
title: 'Workspace Audit',
|
|
50
|
+
subtitle: 'Read-only local scan: Git, tests, CI, size, security, handover report',
|
|
51
|
+
scan: 'Run audit',
|
|
52
|
+
scanning: 'Scanning…',
|
|
53
|
+
refresh: 'Refresh',
|
|
54
|
+
addRoot: 'Add workspace path',
|
|
55
|
+
rootPlaceholder: 'e.g. D:\\MyProject',
|
|
56
|
+
add: 'Add',
|
|
57
|
+
empty: 'No workspaces yet. Add a directory or wait for the DSH workspace registry.',
|
|
58
|
+
score: 'Overall score',
|
|
59
|
+
workspaces: 'Workspaces',
|
|
60
|
+
p0: 'P0 risks',
|
|
61
|
+
risks: 'Risks',
|
|
62
|
+
dimensions: 'Dimensions',
|
|
63
|
+
report: 'Handover report',
|
|
64
|
+
copy: 'Copy report',
|
|
65
|
+
copied: 'Copied',
|
|
66
|
+
download: 'Download Markdown',
|
|
67
|
+
failed: 'Scan failed',
|
|
68
|
+
loading: 'Preparing…',
|
|
69
|
+
path: 'Path',
|
|
70
|
+
git: 'Git',
|
|
71
|
+
size: 'Size',
|
|
72
|
+
tests: 'Test files',
|
|
73
|
+
todo: 'TODO',
|
|
74
|
+
noRisk: 'No blocking risks found',
|
|
75
|
+
commands: 'See each project README and report for run/test commands.',
|
|
76
|
+
filterAll: 'All risks',
|
|
77
|
+
comparison: 'Workspace comparison',
|
|
78
|
+
trend: 'Trend',
|
|
79
|
+
deps: 'Deps',
|
|
80
|
+
vulns: 'Vulns',
|
|
81
|
+
github: 'GitHub',
|
|
82
|
+
release: 'Release',
|
|
83
|
+
branch: 'Branch protection',
|
|
84
|
+
noLock: 'No lockfile',
|
|
85
|
+
};
|
|
86
|
+
const page = { display: 'flex', flexDirection: 'column', gap: '14px', padding: '24px 32px', minHeight: '100%', overflow: 'auto', color: 'var(--dsw-alias-label-primary)' };
|
|
87
|
+
const card = { border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '14px', padding: '14px', background: 'var(--dsw-alias-bg-layer-1)' };
|
|
88
|
+
const money = (bytes) => {
|
|
89
|
+
const value = Number(bytes) || 0;
|
|
90
|
+
if (value < 1024 * 1024) return Math.round(value / 1024) + 'KB';
|
|
91
|
+
if (value < 1024 * 1024 * 1024) return (value / 1024 / 1024).toFixed(1) + 'MB';
|
|
92
|
+
return (value / 1024 / 1024 / 1024).toFixed(1) + 'GB';
|
|
93
|
+
};
|
|
94
|
+
const grade = (score) => (score >= 85 ? 'A' : score >= 70 ? 'B' : score >= 55 ? 'C' : 'D');
|
|
95
|
+
const color = (score) => (score >= 85 ? 'var(--dsw-alias-state-success-primary)' : score >= 70 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)');
|
|
96
|
+
|
|
97
|
+
function Panel() {
|
|
98
|
+
const t = (key) => { const dict = /^zh/i.test(String(window.navigator && window.navigator.language)) ? zh : en; return dict[key] || key; };
|
|
99
|
+
const [token, setToken] = React.useState('');
|
|
100
|
+
const [scan, setScan] = React.useState(null);
|
|
101
|
+
const [state, setState] = React.useState('loading');
|
|
102
|
+
const [rootDraft, setRootDraft] = React.useState('');
|
|
103
|
+
const [riskFilter, setRiskFilter] = React.useState('all');
|
|
104
|
+
const [copied, setCopied] = React.useState(false);
|
|
105
|
+
const auth = () => token.length > 0 ? Promise.resolve(token) : fetch('/api/workspace-audit/auth').then((res) => res.json()).then((payload) => { setToken(payload.token); return payload.token; });
|
|
106
|
+
const headers = (value) => ({ 'x-workspace-audit-token': value, accept: 'application/json' });
|
|
107
|
+
const load = () => {
|
|
108
|
+
auth().then((value) => fetch('/api/workspace-audit/scan', { headers: headers(value), cache: 'no-store' }))
|
|
109
|
+
.then((res) => { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
|
|
110
|
+
.then((payload) => { setScan(payload); setState('ready'); })
|
|
111
|
+
.catch(() => setState('failed'));
|
|
112
|
+
};
|
|
113
|
+
const addRoot = () => {
|
|
114
|
+
auth().then((value) => fetch('/api/workspace-audit/settings', { method: 'POST', headers: { ...headers(value), 'content-type': 'application/json' }, body: JSON.stringify({ add: rootDraft.trim() }) }))
|
|
115
|
+
.then(() => { setRootDraft(''); return load(); })
|
|
116
|
+
.catch(() => {});
|
|
117
|
+
};
|
|
118
|
+
const copy = () => { if (!scan) return; navigator.clipboard.writeText(scan.report).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }).catch(() => {}); };
|
|
119
|
+
const download = () => {
|
|
120
|
+
if (!scan) return;
|
|
121
|
+
const blob = new Blob([scan.report], { type: 'text/markdown;charset=utf-8' });
|
|
122
|
+
const link = document.createElement('a');
|
|
123
|
+
link.href = URL.createObjectURL(blob);
|
|
124
|
+
link.download = 'workspace-audit.md';
|
|
125
|
+
link.click();
|
|
126
|
+
URL.revokeObjectURL(link.href);
|
|
127
|
+
};
|
|
128
|
+
React.useEffect(() => { load(); }, []);
|
|
129
|
+
if (state === 'loading') return h('div', { style: page }, h('h1', { style: { margin: 0, fontSize: '22px' } }, t('title')), h('p', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('scanning')));
|
|
130
|
+
if (state === 'failed' || scan === null) return h('div', { style: page }, h('h1', { style: { margin: 0, fontSize: '22px' } }, t('title')), h('p', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('failed')), h('button', { type: 'button', onClick: load }, t('scan')));
|
|
131
|
+
const workspaces = Array.isArray(scan.workspaces) ? scan.workspaces : [];
|
|
132
|
+
const filteredWorkspaces = workspaces.filter((workspace) => riskFilter === 'all' || (workspace.risks || []).some((risk) => risk.severity === riskFilter));
|
|
133
|
+
const avg = workspaces.length > 0 ? Math.round(workspaces.reduce((sum, workspace) => sum + workspace.score, 0) / workspaces.length) : 0;
|
|
134
|
+
const p0 = workspaces.reduce((sum, workspace) => sum + workspace.risks.filter((risk) => risk.severity === 'P0').length, 0);
|
|
135
|
+
return h('div', { style: page },
|
|
136
|
+
h('header', { style: { display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' } },
|
|
137
|
+
h('div', { style: { flex: '1 1 auto' } }, h('h1', { style: { margin: 0, fontSize: '22px' } }, t('title')), h('p', { style: { margin: '4px 0 0', color: 'var(--dsw-alias-label-secondary)', fontSize: '13px' } }, t('subtitle'))),
|
|
138
|
+
h('button', { type: 'button', onClick: load, style: { padding: '6px 12px', borderRadius: '8px' } }, t('scan')),
|
|
139
|
+
),
|
|
140
|
+
h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '10px' } },
|
|
141
|
+
h('div', { style: card }, h('div', { style: { fontSize: '24px', fontWeight: 700, color: color(avg) } }, avg + ' / ' + grade(avg)), h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('score'))),
|
|
142
|
+
h('div', { style: card }, h('div', { style: { fontSize: '24px', fontWeight: 700 } }, String(workspaces.length)), h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('workspaces'))),
|
|
143
|
+
h('div', { style: card }, h('div', { style: { fontSize: '24px', fontWeight: 700, color: 'var(--dsw-alias-state-error-primary)' } }, String(p0)), h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('p0'))),
|
|
144
|
+
),
|
|
145
|
+
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } },
|
|
146
|
+
h('input', { value: rootDraft, placeholder: t('rootPlaceholder'), onChange: (event) => setRootDraft(event.target.value), style: { flex: '1 1 260px', padding: '6px 8px' } }),
|
|
147
|
+
h('button', { type: 'button', onClick: addRoot, disabled: rootDraft.trim().length === 0 }, t('add')),
|
|
148
|
+
['all', 'P0', 'P1', 'P2'].map((severity) => h('button', { key: severity, type: 'button', onClick: () => setRiskFilter(severity), style: { fontWeight: riskFilter === severity ? 700 : 400 } }, severity === 'all' ? t('filterAll') : severity)),
|
|
149
|
+
h('button', { type: 'button', onClick: copy }, copied ? t('copied') : t('copy')),
|
|
150
|
+
h('button', { type: 'button', onClick: download }, t('download')),
|
|
151
|
+
),
|
|
152
|
+
workspaces.length === 0 ? h('p', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('empty')) : null,
|
|
153
|
+
workspaces.length > 0 ? h('section', { style: card },
|
|
154
|
+
h('h2', { style: { margin: 0, fontSize: '16px' } }, t('comparison')),
|
|
155
|
+
h('div', { style: { overflowX: 'auto', marginTop: '8px' } },
|
|
156
|
+
h('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: '11px' } },
|
|
157
|
+
h('thead', null, h('tr', null, ['workspace', 'score', 'documentation', 'development', 'debugging', 'performance', 'functionality', 'security', 'github', 'trend'].map((key) => h('th', { key, style: { textAlign: 'left', padding: '4px 6px', borderBottom: '1px solid var(--dsw-alias-border-l1)' } }, key)))),
|
|
158
|
+
h('tbody', null, workspaces.map((workspace) => h('tr', { key: workspace.path },
|
|
159
|
+
h('td', { style: { padding: '4px 6px' } }, workspace.title),
|
|
160
|
+
h('td', { style: { padding: '4px 6px', fontWeight: 700, color: color(workspace.score) } }, workspace.score),
|
|
161
|
+
...['documentation', 'development', 'debugging', 'performance', 'functionality', 'security', 'github'].map((key) => h('td', { key, style: { padding: '4px 6px' } }, workspace.dimensions[key])),
|
|
162
|
+
h('td', { key: 'trend', style: { padding: '4px 6px', color: workspace.trend && workspace.trend.delta > 0 ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-secondary)' } }, workspace.trend && workspace.trend.delta !== null ? (workspace.trend.delta >= 0 ? '+' : '') + workspace.trend.delta : '—'),
|
|
163
|
+
))),
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
) : null,
|
|
167
|
+
filteredWorkspaces.map((workspace) => {
|
|
168
|
+
const risks = workspace.risks || [];
|
|
169
|
+
return h('section', { key: workspace.path, style: card },
|
|
170
|
+
h('div', { style: { display: 'flex', gap: '10px', alignItems: 'baseline', flexWrap: 'wrap' } },
|
|
171
|
+
h('h2', { style: { margin: 0, fontSize: '16px', flex: '1 1 auto' } }, workspace.title),
|
|
172
|
+
workspace.trend && workspace.trend.delta !== null ? h('span', { style: { fontSize: '12px', color: workspace.trend.delta >= 0 ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } }, (workspace.trend.delta >= 0 ? '+' : '') + workspace.trend.delta) : null,
|
|
173
|
+
h('strong', { style: { fontSize: '20px', color: color(workspace.score) } }, workspace.score + ' ' + grade(workspace.score)),
|
|
174
|
+
),
|
|
175
|
+
h('div', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', margin: '4px 0 8px' } }, t('path') + ':' + workspace.path),
|
|
176
|
+
h('div', { style: { display: 'flex', gap: '14px', flexWrap: 'wrap', fontSize: '12px' } },
|
|
177
|
+
h('span', null, t('git') + ': ' + (workspace.git.isRepo ? workspace.git.branch || 'repo' : 'none')),
|
|
178
|
+
h('span', null, t('size') + ': ' + money(workspace.size.bytes)),
|
|
179
|
+
h('span', null, t('tests') + ': ' + workspace.testFiles),
|
|
180
|
+
h('span', null, t('todo') + ': ' + workspace.todo),
|
|
181
|
+
),
|
|
182
|
+
h('div', { style: { display: 'flex', gap: '14px', flexWrap: 'wrap', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', marginTop: '4px' } },
|
|
183
|
+
workspace.dependencies && workspace.dependencies.manifests.length > 0
|
|
184
|
+
? h('span', null, t('deps') + ': ' + (workspace.dependencies.lockfiles.length > 0 ? 'lock ✓' : t('noLock')) + ' · ' + t('vulns') + ' ' + workspace.dependencies.vulnerabilities.total + (workspace.dependencies.vulnerabilities.status === 'skipped' ? ' (skipped)' : ''))
|
|
185
|
+
: null,
|
|
186
|
+
workspace.github
|
|
187
|
+
? h('span', null, t('github') + ': ' + workspace.github.owner + '/' + workspace.github.repo + ' · ' + t('release') + ' ' + (workspace.github.latestRelease || '—') + ' · ' + t('branch') + ' ' + String(workspace.github.branchProtected))
|
|
188
|
+
: null,
|
|
189
|
+
),
|
|
190
|
+
h('div', { style: { display: 'flex', gap: '4px', flexWrap: 'wrap', marginTop: '8px' } },
|
|
191
|
+
Object.entries(workspace.dimensions || {}).map(([key, value]) => h('span', { key, style: { fontSize: '10px', padding: '1px 6px', borderRadius: '999px', background: 'var(--dsw-alias-bg-layer-2)' } }, key + ' ' + value)),
|
|
192
|
+
),
|
|
193
|
+
h('div', { style: { marginTop: '10px' } },
|
|
194
|
+
risks.length === 0 ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-state-success-primary)' } }, t('noRisk')) : risks.map((risk, index) =>
|
|
195
|
+
h('div', { key: index, style: { fontSize: '12px', marginTop: '4px', color: risk.severity === 'P0' ? 'var(--dsw-alias-state-error-primary)' : risk.severity === 'P1' ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-label-secondary)' } }, '[' + risk.severity + '] ' + risk.text),
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
}),
|
|
200
|
+
h('p', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('commands')),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function SidebarIcon(props) {
|
|
205
|
+
return h('svg', { width: props.size || 18, height: props.size || 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
|
|
206
|
+
h('path', { d: 'M4 19V5' }), h('path', { d: 'M4 12h5l2 3h9' }), h('path', { d: 'M9 7h5' }), h('path', { d: 'M9 10h7' }));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function apply(ctx) {
|
|
210
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'workspace-audit: dictionaries');
|
|
211
|
+
const t = ctx.locale.bind(NS);
|
|
212
|
+
ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS }, Panel));
|
|
213
|
+
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 22, label: () => t('panel'), locale: NS }, SidebarIcon));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { inject: ['slots', 'locale', 'layout'], apply };
|
|
217
|
+
},
|
|
218
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-workspace-audit Host half.
|
|
3
|
+
* Read-only, local-first workspace health checks with a Markdown handover report.
|
|
4
|
+
*/
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const ROUTE = '/api/workspace-audit';
|
|
14
|
+
const AUTH_PATH = ROUTE + '/auth';
|
|
15
|
+
const SCAN_PATH = ROUTE + '/scan';
|
|
16
|
+
const SETTINGS_PATH = ROUTE + '/settings';
|
|
17
|
+
const MAX_WORKSPACES = 20;
|
|
18
|
+
const SKIP_NAMES = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', '.pytest_cache', '.ruff_cache', '.mypy_cache', 'dist', 'build', 'target', '.next', '.nuxt', 'coverage']);
|
|
19
|
+
const ARTIFACT_NAMES = new Set(['node_modules', '.venv', 'venv', '__pycache__', '.pytest_cache', '.ruff_cache', '.mypy_cache', 'dist', 'build', 'target', '.next', '.nuxt', 'coverage', 'release-archive', 'test+files', 'BeeOffice-v5.2.3-windows', 'BeeOffice_logs']);
|
|
20
|
+
const GITHUB_CACHE = new Map();
|
|
21
|
+
let authToken = null;
|
|
22
|
+
|
|
23
|
+
function resolveDshHome() {
|
|
24
|
+
if (typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim().length > 0) return process.env.DSH_HOME.trim();
|
|
25
|
+
return path.join(os.homedir(), '.dsh');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function settingsFile() {
|
|
29
|
+
return path.join(resolveDshHome(), 'workspace-audit', 'settings.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function loadSettings() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = JSON.parse(await fs.readFile(settingsFile(), 'utf8'));
|
|
35
|
+
return { roots: Array.isArray(raw.roots) ? raw.roots.filter((entry) => typeof entry === 'string').slice(0, 30) : [] };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return { roots: [] };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function historyFile() {
|
|
42
|
+
return path.join(resolveDshHome(), 'workspace-audit', 'history.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function loadHistory() {
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.parse(await fs.readFile(historyFile(), 'utf8'));
|
|
48
|
+
return Array.isArray(raw.entries) ? raw.entries.slice(-30) : [];
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function saveHistory(entries) {
|
|
55
|
+
await fs.mkdir(path.dirname(historyFile()), { recursive: true });
|
|
56
|
+
await fs.writeFile(historyFile(), JSON.stringify({ entries: entries.slice(-30) }, null, 2) + '\n', 'utf8');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function saveSettings(settings) {
|
|
60
|
+
await fs.mkdir(path.dirname(settingsFile()), { recursive: true });
|
|
61
|
+
await fs.writeFile(settingsFile(), JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sendJson(res, status, payload) {
|
|
65
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
66
|
+
res.end(JSON.stringify(payload));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function matchesPath(req, expected) {
|
|
70
|
+
try {
|
|
71
|
+
return new URL(req.url || '/', 'http://localhost').pathname === expected;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isLoopback(req) {
|
|
78
|
+
const address = String(req.socket && req.socket.remoteAddress || '');
|
|
79
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function requireToken(req, res) {
|
|
83
|
+
const provided = req.headers['x-workspace-audit-token'];
|
|
84
|
+
if (typeof provided === 'string' && authToken !== null && provided === authToken) return true;
|
|
85
|
+
sendJson(res, 401, { error: 'unauthorized' });
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createAuthRoute() {
|
|
90
|
+
return async (req, res) => {
|
|
91
|
+
if (!matchesPath(req, AUTH_PATH)) {
|
|
92
|
+
sendJson(res, 404, { error: 'not found' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!isLoopback(req)) {
|
|
96
|
+
sendJson(res, 403, { error: 'loopback only' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (authToken === null) authToken = randomBytes(24).toString('hex');
|
|
100
|
+
sendJson(res, 200, { token: authToken });
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createReadRoute(endpoint, build) {
|
|
105
|
+
return async (req, res) => {
|
|
106
|
+
if (req.method !== 'GET') {
|
|
107
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!matchesPath(req, endpoint)) {
|
|
111
|
+
sendJson(res, 404, { error: 'not found' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (!requireToken(req, res)) return;
|
|
115
|
+
try {
|
|
116
|
+
sendJson(res, 200, await build(req));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
sendJson(res, 500, { error: String((error && error.message) || error) });
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function readJsonBody(req) {
|
|
124
|
+
const chunks = [];
|
|
125
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
126
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function discoverWorkspaces(settings) {
|
|
130
|
+
const roots = new Set(settings.roots);
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(await fs.readFile(path.join(resolveDshHome(), 'storages', 'workspace.json'), 'utf8'));
|
|
133
|
+
const table = parsed && parsed.tables && parsed.tables.workspaces;
|
|
134
|
+
for (const workspace of table && typeof table === 'object' ? Object.values(table) : []) {
|
|
135
|
+
if (workspace && typeof workspace.path === 'string' && workspace.path.length > 0) roots.add(workspace.path);
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// workspace registry is optional
|
|
139
|
+
}
|
|
140
|
+
const result = [];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
const addRoot = (root) => {
|
|
143
|
+
if (result.length >= MAX_WORKSPACES) return;
|
|
144
|
+
const resolved = path.resolve(root);
|
|
145
|
+
if (seen.has(resolved)) return;
|
|
146
|
+
seen.add(resolved);
|
|
147
|
+
result.push({ path: resolved, title: path.basename(resolved) || resolved });
|
|
148
|
+
};
|
|
149
|
+
for (const root of roots) {
|
|
150
|
+
if (result.length >= MAX_WORKSPACES) break;
|
|
151
|
+
try {
|
|
152
|
+
const stat = await fs.stat(root);
|
|
153
|
+
if (!stat.isDirectory()) continue;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
addRoot(root);
|
|
158
|
+
// A multi-project container (for example D:\DSTools) is useful as a parent,
|
|
159
|
+
// and its immediate Git repositories are useful as individual workspaces.
|
|
160
|
+
if (!(await fs.stat(path.join(root, '.git')).catch(() => null))) {
|
|
161
|
+
const children = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
162
|
+
for (const child of children) {
|
|
163
|
+
if (result.length >= MAX_WORKSPACES) break;
|
|
164
|
+
if (!child.isDirectory() || SKIP_NAMES.has(child.name) || child.name.startsWith('.')) continue;
|
|
165
|
+
const gitPath = path.join(root, child.name, '.git');
|
|
166
|
+
const isRepo = await fs.stat(gitPath).catch(() => null);
|
|
167
|
+
if (isRepo) addRoot(path.join(root, child.name));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function gitInfo(root) {
|
|
175
|
+
const run = async (args) => {
|
|
176
|
+
try {
|
|
177
|
+
const result = await execFileAsync('git', ['-C', root, ...args], { timeout: 8000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 });
|
|
178
|
+
return String(result.stdout || '').trim();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return '';
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const isRepo = (await run(['rev-parse', '--is-inside-work-tree'])) === 'true';
|
|
184
|
+
if (!isRepo) return { isRepo: false };
|
|
185
|
+
const remote = await run(['remote', 'get-url', 'origin']);
|
|
186
|
+
const branch = await run(['branch', '--show-current']);
|
|
187
|
+
const status = await run(['status', '--porcelain']);
|
|
188
|
+
const last = await run(['log', '-1', '--format=%h|%ad|%s', '--date=short']);
|
|
189
|
+
const tags = await run(['tag', '--sort=-v:refname']);
|
|
190
|
+
const tracked = await run(['ls-files']);
|
|
191
|
+
return {
|
|
192
|
+
isRepo: true,
|
|
193
|
+
remote,
|
|
194
|
+
branch,
|
|
195
|
+
dirty: status.length === 0 ? 0 : status.split(/\r?\n/).filter(Boolean).length,
|
|
196
|
+
last,
|
|
197
|
+
tags: tags.split(/\r?\n/).filter(Boolean).slice(0, 5),
|
|
198
|
+
tracked: tracked.length === 0 ? [] : tracked.split(/\r?\n/),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function commandExists(command) {
|
|
203
|
+
try {
|
|
204
|
+
await execFileAsync(command, ['--version'], { timeout: 5000, windowsHide: true });
|
|
205
|
+
return true;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function dependencyHealth(root) {
|
|
212
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
213
|
+
const names = entries.map((entry) => entry.name);
|
|
214
|
+
const manifests = names.filter((name) => /^(package\.json|pyproject\.toml|requirements.*\.txt|Cargo\.toml|go\.mod|pom\.xml|build\.gradle)$/i.test(name));
|
|
215
|
+
const lockfiles = names.filter((name) => /^(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|poetry\.lock|uv\.lock|Cargo\.lock|go\.sum|gradle\.lockfile)$/i.test(name));
|
|
216
|
+
let unpinned = 0;
|
|
217
|
+
const requirementFile = names.find((name) => /^requirements.*\.txt$/i.test(name));
|
|
218
|
+
if (requirementFile) {
|
|
219
|
+
const text = await fs.readFile(path.join(root, requirementFile), 'utf8').catch(() => '');
|
|
220
|
+
unpinned += (text.match(/^[A-Za-z0-9_.-]+\s*(>=|~=|>)/gm) || []).length;
|
|
221
|
+
}
|
|
222
|
+
const vulnerabilities = { status: 'skipped', total: 0, tool: '' };
|
|
223
|
+
if (names.includes('package-lock.json')) {
|
|
224
|
+
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
225
|
+
if (await commandExists(npmCommand)) {
|
|
226
|
+
try {
|
|
227
|
+
const result = await execFileAsync(npmCommand, ['audit', '--json', '--package-lock-only', '--omit=dev'], { cwd: root, timeout: 30000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
|
|
228
|
+
const parsed = JSON.parse(String(result.stdout || '{}'));
|
|
229
|
+
vulnerabilities.status = 'checked';
|
|
230
|
+
vulnerabilities.total = Number(parsed.metadata && parsed.metadata.vulnerabilities && parsed.metadata.vulnerabilities.total) || 0;
|
|
231
|
+
vulnerabilities.tool = 'npm audit';
|
|
232
|
+
} catch (error) {
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(String(error.stdout || '{}'));
|
|
235
|
+
vulnerabilities.status = 'checked';
|
|
236
|
+
vulnerabilities.total = Number(parsed.metadata && parsed.metadata.vulnerabilities && parsed.metadata.vulnerabilities.total) || 0;
|
|
237
|
+
vulnerabilities.tool = 'npm audit';
|
|
238
|
+
} catch (inner) {
|
|
239
|
+
vulnerabilities.status = 'error';
|
|
240
|
+
vulnerabilities.tool = 'npm audit';
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} else if (requirementFile) {
|
|
245
|
+
const pipAudit = process.platform === 'win32' ? 'pip-audit.exe' : 'pip-audit';
|
|
246
|
+
if (await commandExists(pipAudit)) {
|
|
247
|
+
try {
|
|
248
|
+
const result = await execFileAsync(pipAudit, ['-r', requirementFile, '--format', 'json'], { cwd: root, timeout: 30000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
|
|
249
|
+
const parsed = JSON.parse(String(result.stdout || '[]'));
|
|
250
|
+
vulnerabilities.status = 'checked';
|
|
251
|
+
vulnerabilities.total = Array.isArray(parsed) ? parsed.reduce((sum, entry) => sum + (Array.isArray(entry.vulns) ? entry.vulns.length : 0), 0) : 0;
|
|
252
|
+
vulnerabilities.tool = 'pip-audit';
|
|
253
|
+
} catch (error) {
|
|
254
|
+
vulnerabilities.status = 'error';
|
|
255
|
+
vulnerabilities.tool = 'pip-audit';
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { manifests, lockfiles, unpinned, vulnerabilities };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseGitHubRemote(remote) {
|
|
263
|
+
const match = String(remote || '').match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
|
264
|
+
return match ? { owner: match[1], repo: match[2] } : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function githubHealth(git) {
|
|
268
|
+
const parsed = parseGitHubRemote(git.remote);
|
|
269
|
+
if (!parsed) return null;
|
|
270
|
+
const key = parsed.owner.toLowerCase() + '/' + parsed.repo.toLowerCase();
|
|
271
|
+
const cached = GITHUB_CACHE.get(key);
|
|
272
|
+
if (cached && Date.now() - cached.at < 10 * 60 * 1000) return cached.value;
|
|
273
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '';
|
|
274
|
+
const headers = { accept: 'application/vnd.github+json', 'user-agent': 'dsh-workspace-audit' };
|
|
275
|
+
if (token) headers.authorization = 'Bearer ' + token;
|
|
276
|
+
const request = async (url) => {
|
|
277
|
+
const controller = new AbortController();
|
|
278
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
279
|
+
try {
|
|
280
|
+
return await fetch(url, { headers, signal: controller.signal });
|
|
281
|
+
} finally {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const value = { provider: 'github', owner: parsed.owner, repo: parsed.repo };
|
|
286
|
+
try {
|
|
287
|
+
const repoRes = await request('https://api.github.com/repos/' + parsed.owner + '/' + parsed.repo);
|
|
288
|
+
if (repoRes.ok) {
|
|
289
|
+
const repo = await repoRes.json();
|
|
290
|
+
value.openIssues = repo.open_issues_count;
|
|
291
|
+
value.stars = repo.stargazers_count;
|
|
292
|
+
value.archived = repo.archived === true;
|
|
293
|
+
value.pushedAt = repo.pushed_at;
|
|
294
|
+
value.defaultBranch = repo.default_branch;
|
|
295
|
+
value.permissions = repo.permissions || null;
|
|
296
|
+
} else {
|
|
297
|
+
value.status = repoRes.status;
|
|
298
|
+
}
|
|
299
|
+
const releaseRes = await request('https://api.github.com/repos/' + parsed.owner + '/' + parsed.repo + '/releases/latest');
|
|
300
|
+
if (releaseRes.ok) {
|
|
301
|
+
const release = await releaseRes.json();
|
|
302
|
+
value.latestRelease = release.tag_name;
|
|
303
|
+
value.releaseName = release.name;
|
|
304
|
+
value.releasePublishedAt = release.published_at;
|
|
305
|
+
} else {
|
|
306
|
+
value.latestRelease = '';
|
|
307
|
+
}
|
|
308
|
+
const branch = git.branch || value.defaultBranch || 'main';
|
|
309
|
+
const protectionRes = await request('https://api.github.com/repos/' + parsed.owner + '/' + parsed.repo + '/branches/' + encodeURIComponent(branch) + '/protection');
|
|
310
|
+
value.branchProtected = protectionRes.ok ? true : protectionRes.status === 403 ? 'unknown' : false;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
value.status = 'error';
|
|
313
|
+
}
|
|
314
|
+
GITHUB_CACHE.set(key, { at: Date.now(), value });
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function fileSize(file) {
|
|
319
|
+
try {
|
|
320
|
+
return (await fs.stat(file)).size;
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function directorySize(root, maxEntries) {
|
|
327
|
+
let total = 0;
|
|
328
|
+
let entries = 0;
|
|
329
|
+
const stack = [root];
|
|
330
|
+
while (stack.length > 0 && entries < maxEntries) {
|
|
331
|
+
const current = stack.pop();
|
|
332
|
+
let items;
|
|
333
|
+
try {
|
|
334
|
+
items = await fs.readdir(current, { withFileTypes: true });
|
|
335
|
+
} catch (error) {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
for (const item of items) {
|
|
339
|
+
if (entries >= maxEntries) break;
|
|
340
|
+
entries += 1;
|
|
341
|
+
const full = path.join(current, item.name);
|
|
342
|
+
if (item.isDirectory()) {
|
|
343
|
+
if (SKIP_NAMES.has(item.name)) continue;
|
|
344
|
+
stack.push(full);
|
|
345
|
+
} else if (item.isFile()) {
|
|
346
|
+
total += await fileSize(full);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { total, entries };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function isTextFile(name) {
|
|
354
|
+
return /\.(py|js|mjs|cjs|ts|tsx|jsx|json|ya?ml|toml|md|txt|cfg|ini|sh|ps1|bat|cmd|go|rs|c|h|cpp|java|kt|rb|php|sql|css|html)$/i.test(name);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function testFilePattern(name) {
|
|
358
|
+
return /(^|\/)(tests?\/|test_[^/]+\.py$|[^/]+\.(test|spec)\.(js|ts|jsx|tsx|mjs)$|.*_test\.(go|rs|py)$)/i.test(name);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function scanWorkspace(workspace) {
|
|
362
|
+
const root = workspace.path;
|
|
363
|
+
const git = await gitInfo(root);
|
|
364
|
+
const dependencies = await dependencyHealth(root);
|
|
365
|
+
const github = await githubHealth(git);
|
|
366
|
+
const tracked = Array.isArray(git.tracked) ? git.tracked : [];
|
|
367
|
+
const rootEntries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
368
|
+
const markers = {
|
|
369
|
+
readme: rootEntries.some((entry) => /^readme(\.|$)/i.test(entry.name)),
|
|
370
|
+
license: rootEntries.some((entry) => /^licen[cs]e(\.|$)/i.test(entry.name)),
|
|
371
|
+
gitignore: rootEntries.some((entry) => entry.name === '.gitignore'),
|
|
372
|
+
docker: rootEntries.some((entry) => /^(dockerfile|docker-compose\.ya?ml)$/i.test(entry.name)),
|
|
373
|
+
manifest: rootEntries.some((entry) => /^(package\.json|pyproject\.toml|requirements\.txt|cargo\.toml|go\.mod|pom\.xml|build\.gradle)$/i.test(entry.name)),
|
|
374
|
+
lockfile: rootEntries.some((entry) => /^(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|poetry\.lock|uv\.lock|cargo\.lock|go\.sum)$/i.test(entry.name)),
|
|
375
|
+
tests: rootEntries.some((entry) => /^tests?$/i.test(entry.name)),
|
|
376
|
+
ci: rootEntries.some((entry) => entry.name === '.github'),
|
|
377
|
+
docs: rootEntries.some((entry) => /^docs?$/i.test(entry.name)),
|
|
378
|
+
};
|
|
379
|
+
const artifacts = [];
|
|
380
|
+
for (const entry of rootEntries) {
|
|
381
|
+
if (entry.isDirectory() && ARTIFACT_NAMES.has(entry.name)) {
|
|
382
|
+
const size = await directorySize(path.join(root, entry.name), 5000);
|
|
383
|
+
artifacts.push({ name: entry.name, bytes: size.total });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const size = await directorySize(root, 20000);
|
|
387
|
+
const sourceFiles = tracked.filter((file) => isTextFile(file) && !file.includes('node_modules/')).slice(0, 400);
|
|
388
|
+
let todo = 0;
|
|
389
|
+
let dangerous = 0;
|
|
390
|
+
const secretFiles = [];
|
|
391
|
+
for (const file of tracked.slice(0, 3000)) {
|
|
392
|
+
const sourceLike = /\.(py|js|mjs|cjs|ts|tsx|jsx|go|rs|c|h|cpp|java|kt|rb|php|sql)$/i.test(file);
|
|
393
|
+
const basename = path.basename(file).toLowerCase();
|
|
394
|
+
const secretLike = !sourceLike && (
|
|
395
|
+
/(^|\/)\.env(\.|$)/i.test(file) ||
|
|
396
|
+
/\.(pem|key|p12|pfx|jks|keystore)$/i.test(file) ||
|
|
397
|
+
/(^|\/)(secrets?|credentials?)(\.(json|ya?ml|txt|ini|cfg)|$)/i.test(file) ||
|
|
398
|
+
basename === '.npmrc' || basename === '.pypirc'
|
|
399
|
+
);
|
|
400
|
+
if (secretLike) secretFiles.push(file);
|
|
401
|
+
}
|
|
402
|
+
for (const file of sourceFiles) {
|
|
403
|
+
const full = path.join(root, file);
|
|
404
|
+
if (!/\.(py|js|mjs|cjs|ts|tsx|jsx|sh|ps1|bat|cmd|yml|yaml)$/i.test(file)) continue;
|
|
405
|
+
const text = await fs.readFile(full, 'utf8').catch(() => '');
|
|
406
|
+
if (text.length === 0) continue;
|
|
407
|
+
todo += (text.match(/\b(TODO|FIXME|XXX|HACK)\b/g) || []).length;
|
|
408
|
+
dangerous += (text.match(/shell\s*=\s*True|\bexec\s*\(|pickle\.load\s*\(/g) || []).length;
|
|
409
|
+
}
|
|
410
|
+
const readmeEntry = rootEntries.find((entry) => /^readme/i.test(entry.name));
|
|
411
|
+
const readmeText = readmeEntry ? await fs.readFile(path.join(root, readmeEntry.name), 'utf8').catch(() => '') : '';
|
|
412
|
+
const workflowsDir = path.join(root, '.github', 'workflows');
|
|
413
|
+
const workflowFiles = await fs.readdir(workflowsDir, { withFileTypes: true }).catch(() => []);
|
|
414
|
+
const workflows = [];
|
|
415
|
+
for (const file of workflowFiles.filter((entry) => entry.isFile()).slice(0, 30)) {
|
|
416
|
+
const text = await fs.readFile(path.join(workflowsDir, file.name), 'utf8').catch(() => '');
|
|
417
|
+
workflows.push({
|
|
418
|
+
name: file.name,
|
|
419
|
+
pullRequest: /pull_request/.test(text),
|
|
420
|
+
selfHosted: /self-hosted/.test(text),
|
|
421
|
+
permissions: /permissions:/.test(text),
|
|
422
|
+
publish: /npm publish|twine upload|upload-release|gh release|docker push/.test(text),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
const testFiles = tracked.filter((file) => testFilePattern(file)).length;
|
|
426
|
+
const dimensions = {
|
|
427
|
+
documentation: (markers.readme ? 35 : 0) + (markers.docs ? 25 : 0) + (readmeText.length > 4000 ? 20 : readmeText.length > 1000 ? 10 : 0) + (markers.license ? 20 : 0),
|
|
428
|
+
development: (markers.manifest ? 25 : 0) + (dependencies.lockfiles.length > 0 ? 20 : 0) + (markers.gitignore ? 20 : 0) + (markers.ci ? 25 : 0) + (markers.docker ? 10 : 0),
|
|
429
|
+
debugging: Math.min(100, testFiles * 6 + (markers.tests ? 20 : 0) + (markers.ci ? 20 : 0)),
|
|
430
|
+
performance: Math.max(0, 100 - Math.min(80, Math.round(size.total / (1024 * 1024 * 50) * 10)) - (artifacts.length > 0 ? 20 : 0)),
|
|
431
|
+
functionality: (markers.readme ? 30 : 0) + (tracked.length > 50 ? 35 : tracked.length > 10 ? 20 : 0) + (markers.tests ? 20 : 0) + (markers.ci ? 15 : 0),
|
|
432
|
+
security: Math.max(0, 100 - (git.isRepo && git.dirty > 0 ? 15 : 0) - Math.min(40, dangerous * 5) - (workflows.some((wf) => wf.selfHosted && wf.pullRequest) ? 40 : 0) - (secretFiles.length > 0 ? 20 : 0) - (dependencies.vulnerabilities.total > 0 ? 30 : 0)),
|
|
433
|
+
github: (git.isRepo ? 30 : 0) + (git.remote ? 30 : 0) + (git.dirty === 0 ? 20 : 0) + (git.tags && git.tags.length > 0 ? 20 : 0),
|
|
434
|
+
};
|
|
435
|
+
const score = Math.round(Object.values(dimensions).reduce((sum, value) => sum + value, 0) / 7);
|
|
436
|
+
const risks = [];
|
|
437
|
+
if (!git.isRepo) risks.push({ severity: 'P2', text: '目录未初始化 Git,无法追踪变更、回滚或协作。' });
|
|
438
|
+
if (git.isRepo && git.dirty > 0) risks.push({ severity: 'P1', text: `工作区有 ${git.dirty} 个未提交变更,发布前需确认。` });
|
|
439
|
+
if (git.isRepo && !git.remote) risks.push({ severity: 'P2', text: '仓库没有 origin 远端,缺少异地备份/协作。' });
|
|
440
|
+
if (!markers.readme) risks.push({ severity: 'P1', text: '缺少 README,后来开发者难以启动项目。' });
|
|
441
|
+
if (!markers.ci) risks.push({ severity: 'P1', text: '缺少 CI,测试/构建容易回归。' });
|
|
442
|
+
if (workflows.some((wf) => wf.selfHosted && wf.pullRequest)) risks.push({ severity: 'P0', text: '自托管 Runner 被 pull_request 触发,公开仓库存在不可信代码执行风险。' });
|
|
443
|
+
if (secretFiles.length > 0) risks.push({ severity: 'P1', text: `发现疑似密钥文件:${secretFiles.slice(0, 5).join(', ')}(只检查了文件名,未读取内容)。` });
|
|
444
|
+
if (dangerous > 10) risks.push({ severity: 'P1', text: `发现 ${dangerous} 处 shell=True/exec/pickle.load 高危模式,需确认输入来源。` });
|
|
445
|
+
else if (dangerous > 0) risks.push({ severity: 'P2', text: `发现 ${dangerous} 处 shell=True/exec/pickle.load 模式,建议人工确认。` });
|
|
446
|
+
const significantArtifacts = artifacts.filter((item) => item.bytes >= 1024 * 1024);
|
|
447
|
+
if (significantArtifacts.length > 0) {
|
|
448
|
+
const huge = significantArtifacts.some((item) => item.bytes >= 1024 * 1024 * 1024);
|
|
449
|
+
risks.push({ severity: huge ? 'P1' : 'P2', text: `发现构建/缓存目录:${significantArtifacts.map((item) => item.name + ' ' + Math.round(item.bytes / 1024 / 1024) + 'MB').join(', ')}。` });
|
|
450
|
+
}
|
|
451
|
+
if (dependencies.vulnerabilities.total > 0) risks.push({ severity: dependencies.vulnerabilities.total >= 5 ? 'P0' : 'P1', text: `${dependencies.vulnerabilities.tool || '依赖扫描'} 发现 ${dependencies.vulnerabilities.total} 个依赖漏洞。` });
|
|
452
|
+
if (dependencies.lockfiles.length === 0 && dependencies.manifests.length > 0) risks.push({ severity: 'P2', text: '依赖清单存在但没有检测到锁文件。' });
|
|
453
|
+
if (dependencies.unpinned > 5) risks.push({ severity: 'P2', text: `requirements 中有 ${dependencies.unpinned} 条非精确版本约束。` });
|
|
454
|
+
if (github && github.latestRelease && git.tags && git.tags.length > 0 && github.latestRelease !== git.tags[0]) risks.push({ severity: 'P2', text: `Git tag ${git.tags[0]} 与 GitHub latest release ${github.latestRelease} 不一致。` });
|
|
455
|
+
if (github && github.archived === true) risks.push({ severity: 'P2', text: 'GitHub 仓库已归档(archived)。' });
|
|
456
|
+
if (github && github.branchProtected === false) risks.push({ severity: 'P2', text: 'GitHub 默认分支未检测到保护规则。' });
|
|
457
|
+
if (todo > 20) risks.push({ severity: 'P2', text: `源码中约 ${todo} 处 TODO/FIXME,建议建立清理计划。` });
|
|
458
|
+
return {
|
|
459
|
+
path: root,
|
|
460
|
+
title: workspace.title,
|
|
461
|
+
git,
|
|
462
|
+
markers,
|
|
463
|
+
workflowFiles,
|
|
464
|
+
workflows,
|
|
465
|
+
dependencies,
|
|
466
|
+
github,
|
|
467
|
+
size: { bytes: size.total, scannedEntries: size.entries, artifacts },
|
|
468
|
+
testFiles,
|
|
469
|
+
todo,
|
|
470
|
+
dangerous,
|
|
471
|
+
secretFiles,
|
|
472
|
+
dimensions,
|
|
473
|
+
score,
|
|
474
|
+
risks,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function grade(score) {
|
|
479
|
+
if (score >= 85) return 'A';
|
|
480
|
+
if (score >= 70) return 'B';
|
|
481
|
+
if (score >= 55) return 'C';
|
|
482
|
+
return 'D';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function renderReport(workspaces) {
|
|
486
|
+
const lines = ['# Workspace Audit Report', '', `Generated: ${new Date().toISOString()}`, ''];
|
|
487
|
+
for (const workspace of workspaces) {
|
|
488
|
+
lines.push(`## ${workspace.title}`, '', `- Path: \`${workspace.path}\``, `- Score: **${workspace.score}/100 (${grade(workspace.score)})**`, `- Git: ${workspace.git.isRepo ? workspace.git.branch + ' · ' + (workspace.git.remote || 'no remote') : 'not a repository'}`, `- Size: ${Math.round(workspace.size.bytes / 1024 / 1024)}MB`, `- Test files: ${workspace.testFiles}`, `- TODO/FIXME: ${workspace.todo}`, '');
|
|
489
|
+
lines.push('### Dimensions', '');
|
|
490
|
+
for (const [key, value] of Object.entries(workspace.dimensions)) lines.push(`- ${key}: ${value}/100`);
|
|
491
|
+
if (workspace.trend && workspace.trend.previousScore !== null) lines.push(`- Trend: ${workspace.trend.delta >= 0 ? '+' : ''}${workspace.trend.delta} from ${workspace.trend.previousScore}`);
|
|
492
|
+
if (workspace.dependencies && (workspace.dependencies.manifests.length > 0 || workspace.dependencies.lockfiles.length > 0)) {
|
|
493
|
+
lines.push(`- Dependencies: manifests=${workspace.dependencies.manifests.join(',') || 'none'}; lockfiles=${workspace.dependencies.lockfiles.join(',') || 'none'}; vulnerabilities=${workspace.dependencies.vulnerabilities.total} (${workspace.dependencies.vulnerabilities.status})`);
|
|
494
|
+
}
|
|
495
|
+
if (workspace.github) lines.push(`- GitHub: ${workspace.github.owner}/${workspace.github.repo}; release=${workspace.github.latestRelease || 'none'}; issues=${workspace.github.openIssues ?? '—'}; branchProtected=${workspace.github.branchProtected ?? '—'}`);
|
|
496
|
+
lines.push('', '### Risks', '');
|
|
497
|
+
if (workspace.risks.length === 0) lines.push('- No blocking risks found.');
|
|
498
|
+
for (const risk of workspace.risks) lines.push(`- **${risk.severity}** ${risk.text}`);
|
|
499
|
+
lines.push('');
|
|
500
|
+
}
|
|
501
|
+
return lines.join('\n');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function runScan() {
|
|
505
|
+
const settings = await loadSettings();
|
|
506
|
+
const discovered = await discoverWorkspaces(settings);
|
|
507
|
+
const workspaces = [];
|
|
508
|
+
const failures = [];
|
|
509
|
+
const previousEntries = await loadHistory();
|
|
510
|
+
const previous = previousEntries.length > 0 ? previousEntries[previousEntries.length - 1] : null;
|
|
511
|
+
for (const workspace of discovered) {
|
|
512
|
+
try {
|
|
513
|
+
workspaces.push(await scanWorkspace(workspace));
|
|
514
|
+
} catch (error) {
|
|
515
|
+
failures.push({ path: workspace.path, error: String((error && error.message) || error) });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
workspaces.sort((left, right) => left.score - right.score);
|
|
519
|
+
for (const workspace of workspaces) {
|
|
520
|
+
const before = previous && Array.isArray(previous.workspaces) ? previous.workspaces.find((entry) => entry.path === workspace.path) : null;
|
|
521
|
+
workspace.trend = { previousScore: before ? before.score : null, delta: before ? workspace.score - before.score : null };
|
|
522
|
+
}
|
|
523
|
+
const currentEntry = {
|
|
524
|
+
at: Date.now(),
|
|
525
|
+
workspaces: workspaces.map((workspace) => ({ path: workspace.path, score: workspace.score, dimensions: workspace.dimensions })),
|
|
526
|
+
};
|
|
527
|
+
await saveHistory([...previousEntries, currentEntry]).catch(() => {});
|
|
528
|
+
return { generatedAt: Date.now(), workspaces, failures, discovered: discovered.length, report: renderReport(workspaces) };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function createSettingsRoute() {
|
|
532
|
+
return async (req, res) => {
|
|
533
|
+
if (!matchesPath(req, SETTINGS_PATH)) {
|
|
534
|
+
sendJson(res, 404, { error: 'not found' });
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (req.method === 'GET') {
|
|
538
|
+
if (!requireToken(req, res)) return;
|
|
539
|
+
sendJson(res, 200, { settings: await loadSettings(), workspaces: await discoverWorkspaces(await loadSettings()) });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (req.method !== 'POST') {
|
|
543
|
+
sendJson(res, 405, { error: 'method not allowed' });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (!requireToken(req, res)) return;
|
|
547
|
+
try {
|
|
548
|
+
const body = await readJsonBody(req);
|
|
549
|
+
const current = await loadSettings();
|
|
550
|
+
const requested = [
|
|
551
|
+
...(Array.isArray(body.roots) ? body.roots : []),
|
|
552
|
+
...(typeof body.add === 'string' ? [body.add] : []),
|
|
553
|
+
];
|
|
554
|
+
const roots = [...new Set([...current.roots, ...requested])]
|
|
555
|
+
.map((entry) => String(entry).trim())
|
|
556
|
+
.filter((entry) => entry.length > 0)
|
|
557
|
+
.slice(0, 30);
|
|
558
|
+
const next = { roots };
|
|
559
|
+
await saveSettings(next);
|
|
560
|
+
sendJson(res, 200, { ok: true, settings: next });
|
|
561
|
+
} catch (error) {
|
|
562
|
+
sendJson(res, 400, { error: 'invalid json' });
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function apply(ctx) {
|
|
568
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: AUTH_PATH, handler: createAuthRoute() }), 'workspace-audit: auth');
|
|
569
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: SCAN_PATH, handler: createReadRoute(SCAN_PATH, () => runScan()) }), 'workspace-audit: scan');
|
|
570
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: SETTINGS_PATH, handler: createSettingsRoute() }), 'workspace-audit: settings');
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export { apply, scanWorkspace, discoverWorkspaces, dependencyHealth, githubHealth, parseGitHubRemote, renderReport, grade };
|
|
574
|
+
export const inject = ['webServer'];
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomjerrygit/dsh-workspace-audit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test test/audit.test.mjs"
|
|
7
|
+
},
|
|
8
|
+
"description": "Local-first workspace health audit for DeepSeek Harness: Git, tests, CI, size, security hotspots, and a Markdown handover report.",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"engines": {
|
|
11
|
+
"dsh": ">=0.2.0-rc.2"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./index.js",
|
|
18
|
+
"./client": "./client.js",
|
|
19
|
+
"./icon": "./icon.svg",
|
|
20
|
+
"./locale/*.json": "./locale/*.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"index.js",
|
|
24
|
+
"client.js",
|
|
25
|
+
"cordis.patch.yml",
|
|
26
|
+
"icon.svg",
|
|
27
|
+
"locale/*.json",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"dsh": {
|
|
32
|
+
"manifestVersion": 1,
|
|
33
|
+
"bundle": {
|
|
34
|
+
"patch": "./cordis.patch.yml"
|
|
35
|
+
},
|
|
36
|
+
"client": {
|
|
37
|
+
"platform": "web",
|
|
38
|
+
"immediately": true,
|
|
39
|
+
"inject": [
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-sidebar",
|
|
41
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
42
|
+
"@deepseek-ai/dsh-client-locale"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"author": {
|
|
47
|
+
"name": "TomJerryGit"
|
|
48
|
+
},
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/TomJerryGit/dsh-workspace-audit.git"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://github.com/TomJerryGit/dsh-workspace-audit#readme",
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/TomJerryGit/dsh-workspace-audit/issues"
|
|
57
|
+
},
|
|
58
|
+
"keywords": [
|
|
59
|
+
"dsh-plugin",
|
|
60
|
+
"deepseek-harness",
|
|
61
|
+
"workspace",
|
|
62
|
+
"audit",
|
|
63
|
+
"health",
|
|
64
|
+
"handover",
|
|
65
|
+
"security"
|
|
66
|
+
]
|
|
67
|
+
}
|