@tomjerrygit/dsh-workspace-audit 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/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,29 @@
1
- # Temporary Holding Version
1
+ # @tomjerrygit/dsh-workspace-audit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ - Size: source/scan size and known build/cache directories
10
+ - Security hotspots: self-hosted runner triggered by `pull_request`, suspected key filenames (names only), `shell=True`/`exec`/`pickle.load` patterns
11
+ - Risks: P0/P1/P2 list and a Markdown handover report
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ dsh plugin --profile web add @tomjerrygit/dsh-workspace-audit
17
+ ```
18
+
19
+ Market index:
20
+
21
+ ```text
22
+ https://tomjerrygit.github.io/dsh-workspace-audit/index.json
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ 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.
28
+
29
+ > 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,174 @@
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
+ };
38
+ const en = {
39
+ panel: 'Workspace Audit',
40
+ title: 'Workspace Audit',
41
+ subtitle: 'Read-only local scan: Git, tests, CI, size, security, handover report',
42
+ scan: 'Run audit',
43
+ scanning: 'Scanning…',
44
+ refresh: 'Refresh',
45
+ addRoot: 'Add workspace path',
46
+ rootPlaceholder: 'e.g. D:\\MyProject',
47
+ add: 'Add',
48
+ empty: 'No workspaces yet. Add a directory or wait for the DSH workspace registry.',
49
+ score: 'Overall score',
50
+ workspaces: 'Workspaces',
51
+ p0: 'P0 risks',
52
+ risks: 'Risks',
53
+ dimensions: 'Dimensions',
54
+ report: 'Handover report',
55
+ copy: 'Copy report',
56
+ copied: 'Copied',
57
+ download: 'Download Markdown',
58
+ failed: 'Scan failed',
59
+ loading: 'Preparing…',
60
+ path: 'Path',
61
+ git: 'Git',
62
+ size: 'Size',
63
+ tests: 'Test files',
64
+ todo: 'TODO',
65
+ noRisk: 'No blocking risks found',
66
+ commands: 'See each project README and report for run/test commands.',
67
+ };
68
+ const page = { display: 'flex', flexDirection: 'column', gap: '14px', padding: '24px 32px', minHeight: '100%', overflow: 'auto', color: 'var(--dsw-alias-label-primary)' };
69
+ const card = { border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '14px', padding: '14px', background: 'var(--dsw-alias-bg-layer-1)' };
70
+ const money = (bytes) => {
71
+ const value = Number(bytes) || 0;
72
+ if (value < 1024 * 1024) return Math.round(value / 1024) + 'KB';
73
+ if (value < 1024 * 1024 * 1024) return (value / 1024 / 1024).toFixed(1) + 'MB';
74
+ return (value / 1024 / 1024 / 1024).toFixed(1) + 'GB';
75
+ };
76
+ const grade = (score) => (score >= 85 ? 'A' : score >= 70 ? 'B' : score >= 55 ? 'C' : 'D');
77
+ 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)');
78
+
79
+ function Panel() {
80
+ const t = (key) => { const dict = /^zh/i.test(String(window.navigator && window.navigator.language)) ? zh : en; return dict[key] || key; };
81
+ const [token, setToken] = React.useState('');
82
+ const [scan, setScan] = React.useState(null);
83
+ const [state, setState] = React.useState('loading');
84
+ const [rootDraft, setRootDraft] = React.useState('');
85
+ const [copied, setCopied] = React.useState(false);
86
+ 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; });
87
+ const headers = (value) => ({ 'x-workspace-audit-token': value, accept: 'application/json' });
88
+ const load = () => {
89
+ auth().then((value) => fetch('/api/workspace-audit/scan', { headers: headers(value), cache: 'no-store' }))
90
+ .then((res) => { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
91
+ .then((payload) => { setScan(payload); setState('ready'); })
92
+ .catch(() => setState('failed'));
93
+ };
94
+ const addRoot = () => {
95
+ auth().then((value) => fetch('/api/workspace-audit/settings', { method: 'POST', headers: { ...headers(value), 'content-type': 'application/json' }, body: JSON.stringify({ add: rootDraft.trim() }) }))
96
+ .then(() => { setRootDraft(''); return load(); })
97
+ .catch(() => {});
98
+ };
99
+ const copy = () => { if (!scan) return; navigator.clipboard.writeText(scan.report).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }).catch(() => {}); };
100
+ const download = () => {
101
+ if (!scan) return;
102
+ const blob = new Blob([scan.report], { type: 'text/markdown;charset=utf-8' });
103
+ const link = document.createElement('a');
104
+ link.href = URL.createObjectURL(blob);
105
+ link.download = 'workspace-audit.md';
106
+ link.click();
107
+ URL.revokeObjectURL(link.href);
108
+ };
109
+ React.useEffect(() => { load(); }, []);
110
+ 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')));
111
+ 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')));
112
+ const workspaces = Array.isArray(scan.workspaces) ? scan.workspaces : [];
113
+ const avg = workspaces.length > 0 ? Math.round(workspaces.reduce((sum, workspace) => sum + workspace.score, 0) / workspaces.length) : 0;
114
+ const p0 = workspaces.reduce((sum, workspace) => sum + workspace.risks.filter((risk) => risk.severity === 'P0').length, 0);
115
+ return h('div', { style: page },
116
+ h('header', { style: { display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' } },
117
+ 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'))),
118
+ h('button', { type: 'button', onClick: load, style: { padding: '6px 12px', borderRadius: '8px' } }, t('scan')),
119
+ ),
120
+ h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '10px' } },
121
+ 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'))),
122
+ 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'))),
123
+ 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'))),
124
+ ),
125
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } },
126
+ h('input', { value: rootDraft, placeholder: t('rootPlaceholder'), onChange: (event) => setRootDraft(event.target.value), style: { flex: '1 1 260px', padding: '6px 8px' } }),
127
+ h('button', { type: 'button', onClick: addRoot, disabled: rootDraft.trim().length === 0 }, t('add')),
128
+ h('button', { type: 'button', onClick: copy }, copied ? t('copied') : t('copy')),
129
+ h('button', { type: 'button', onClick: download }, t('download')),
130
+ ),
131
+ workspaces.length === 0 ? h('p', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('empty')) : null,
132
+ ...workspaces.map((workspace) => {
133
+ const risks = workspace.risks || [];
134
+ return h('section', { key: workspace.path, style: card },
135
+ h('div', { style: { display: 'flex', gap: '10px', alignItems: 'baseline', flexWrap: 'wrap' } },
136
+ h('h2', { style: { margin: 0, fontSize: '16px', flex: '1 1 auto' } }, workspace.title),
137
+ h('strong', { style: { fontSize: '20px', color: color(workspace.score) } }, workspace.score + ' ' + grade(workspace.score)),
138
+ ),
139
+ h('div', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', margin: '4px 0 8px' } }, t('path') + ':' + workspace.path),
140
+ h('div', { style: { display: 'flex', gap: '14px', flexWrap: 'wrap', fontSize: '12px' } },
141
+ h('span', null, t('git') + ': ' + (workspace.git.isRepo ? workspace.git.branch || 'repo' : 'none')),
142
+ h('span', null, t('size') + ': ' + money(workspace.size.bytes)),
143
+ h('span', null, t('tests') + ': ' + workspace.testFiles),
144
+ h('span', null, t('todo') + ': ' + workspace.todo),
145
+ ),
146
+ h('div', { style: { display: 'flex', gap: '4px', flexWrap: 'wrap', marginTop: '8px' } },
147
+ 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)),
148
+ ),
149
+ h('div', { style: { marginTop: '10px' } },
150
+ risks.length === 0 ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-state-success-primary)' } }, t('noRisk')) : risks.map((risk, index) =>
151
+ 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),
152
+ ),
153
+ ),
154
+ );
155
+ }),
156
+ h('p', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('commands')),
157
+ );
158
+ }
159
+
160
+ function SidebarIcon(props) {
161
+ 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 },
162
+ h('path', { d: 'M4 19V5' }), h('path', { d: 'M4 12h5l2 3h9' }), h('path', { d: 'M9 7h5' }), h('path', { d: 'M9 10h7' }));
163
+ }
164
+
165
+ function apply(ctx) {
166
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'workspace-audit: dictionaries');
167
+ const t = ctx.locale.bind(NS);
168
+ ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS }, Panel));
169
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 22, label: () => t('panel'), locale: NS }, SidebarIcon));
170
+ }
171
+
172
+ return { inject: ['slots', 'locale', 'layout'], apply };
173
+ },
174
+ });
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: workspace-audit
3
+ name: '@tomjerrygit/dsh-workspace-audit'
package/icon.svg ADDED
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
2
+ <path d="M4 19V5"/><path d="M4 12h5l2 3h9"/><path d="M9 7h5"/><path d="M9 10h7"/>
3
+ </svg>
package/index.js ADDED
@@ -0,0 +1,410 @@
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
+ let authToken = null;
21
+
22
+ function resolveDshHome() {
23
+ if (typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim().length > 0) return process.env.DSH_HOME.trim();
24
+ return path.join(os.homedir(), '.dsh');
25
+ }
26
+
27
+ function settingsFile() {
28
+ return path.join(resolveDshHome(), 'workspace-audit', 'settings.json');
29
+ }
30
+
31
+ async function loadSettings() {
32
+ try {
33
+ const raw = JSON.parse(await fs.readFile(settingsFile(), 'utf8'));
34
+ return { roots: Array.isArray(raw.roots) ? raw.roots.filter((entry) => typeof entry === 'string').slice(0, 30) : [] };
35
+ } catch (error) {
36
+ return { roots: [] };
37
+ }
38
+ }
39
+
40
+ async function saveSettings(settings) {
41
+ await fs.mkdir(path.dirname(settingsFile()), { recursive: true });
42
+ await fs.writeFile(settingsFile(), JSON.stringify(settings, null, 2) + '\n', 'utf8');
43
+ }
44
+
45
+ function sendJson(res, status, payload) {
46
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
47
+ res.end(JSON.stringify(payload));
48
+ }
49
+
50
+ function matchesPath(req, expected) {
51
+ try {
52
+ return new URL(req.url || '/', 'http://localhost').pathname === expected;
53
+ } catch (error) {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function isLoopback(req) {
59
+ const address = String(req.socket && req.socket.remoteAddress || '');
60
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
61
+ }
62
+
63
+ function requireToken(req, res) {
64
+ const provided = req.headers['x-workspace-audit-token'];
65
+ if (typeof provided === 'string' && authToken !== null && provided === authToken) return true;
66
+ sendJson(res, 401, { error: 'unauthorized' });
67
+ return false;
68
+ }
69
+
70
+ function createAuthRoute() {
71
+ return async (req, res) => {
72
+ if (!matchesPath(req, AUTH_PATH)) {
73
+ sendJson(res, 404, { error: 'not found' });
74
+ return;
75
+ }
76
+ if (!isLoopback(req)) {
77
+ sendJson(res, 403, { error: 'loopback only' });
78
+ return;
79
+ }
80
+ if (authToken === null) authToken = randomBytes(24).toString('hex');
81
+ sendJson(res, 200, { token: authToken });
82
+ };
83
+ }
84
+
85
+ function createReadRoute(endpoint, build) {
86
+ return async (req, res) => {
87
+ if (req.method !== 'GET') {
88
+ sendJson(res, 405, { error: 'method not allowed' });
89
+ return;
90
+ }
91
+ if (!matchesPath(req, endpoint)) {
92
+ sendJson(res, 404, { error: 'not found' });
93
+ return;
94
+ }
95
+ if (!requireToken(req, res)) return;
96
+ try {
97
+ sendJson(res, 200, await build(req));
98
+ } catch (error) {
99
+ sendJson(res, 500, { error: String((error && error.message) || error) });
100
+ }
101
+ };
102
+ }
103
+
104
+ async function readJsonBody(req) {
105
+ const chunks = [];
106
+ for await (const chunk of req) chunks.push(chunk);
107
+ return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
108
+ }
109
+
110
+ async function discoverWorkspaces(settings) {
111
+ const roots = new Set(settings.roots);
112
+ try {
113
+ const parsed = JSON.parse(await fs.readFile(path.join(resolveDshHome(), 'storages', 'workspace.json'), 'utf8'));
114
+ const table = parsed && parsed.tables && parsed.tables.workspaces;
115
+ for (const workspace of table && typeof table === 'object' ? Object.values(table) : []) {
116
+ if (workspace && typeof workspace.path === 'string' && workspace.path.length > 0) roots.add(workspace.path);
117
+ }
118
+ } catch (error) {
119
+ // workspace registry is optional
120
+ }
121
+ const result = [];
122
+ const seen = new Set();
123
+ const addRoot = (root) => {
124
+ if (result.length >= MAX_WORKSPACES) return;
125
+ const resolved = path.resolve(root);
126
+ if (seen.has(resolved)) return;
127
+ seen.add(resolved);
128
+ result.push({ path: resolved, title: path.basename(resolved) || resolved });
129
+ };
130
+ for (const root of roots) {
131
+ if (result.length >= MAX_WORKSPACES) break;
132
+ try {
133
+ const stat = await fs.stat(root);
134
+ if (!stat.isDirectory()) continue;
135
+ } catch (error) {
136
+ continue;
137
+ }
138
+ addRoot(root);
139
+ // A multi-project container (for example D:\DSTools) is useful as a parent,
140
+ // and its immediate Git repositories are useful as individual workspaces.
141
+ if (!(await fs.stat(path.join(root, '.git')).catch(() => null))) {
142
+ const children = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
143
+ for (const child of children) {
144
+ if (result.length >= MAX_WORKSPACES) break;
145
+ if (!child.isDirectory() || SKIP_NAMES.has(child.name) || child.name.startsWith('.')) continue;
146
+ const gitPath = path.join(root, child.name, '.git');
147
+ const isRepo = await fs.stat(gitPath).catch(() => null);
148
+ if (isRepo) addRoot(path.join(root, child.name));
149
+ }
150
+ }
151
+ }
152
+ return result;
153
+ }
154
+
155
+ async function gitInfo(root) {
156
+ const run = async (args) => {
157
+ try {
158
+ const result = await execFileAsync('git', ['-C', root, ...args], { timeout: 8000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 });
159
+ return String(result.stdout || '').trim();
160
+ } catch (error) {
161
+ return '';
162
+ }
163
+ };
164
+ const isRepo = (await run(['rev-parse', '--is-inside-work-tree'])) === 'true';
165
+ if (!isRepo) return { isRepo: false };
166
+ const remote = await run(['remote', 'get-url', 'origin']);
167
+ const branch = await run(['branch', '--show-current']);
168
+ const status = await run(['status', '--porcelain']);
169
+ const last = await run(['log', '-1', '--format=%h|%ad|%s', '--date=short']);
170
+ const tags = await run(['tag', '--sort=-v:refname']);
171
+ const tracked = await run(['ls-files']);
172
+ return {
173
+ isRepo: true,
174
+ remote,
175
+ branch,
176
+ dirty: status.length === 0 ? 0 : status.split(/\r?\n/).filter(Boolean).length,
177
+ last,
178
+ tags: tags.split(/\r?\n/).filter(Boolean).slice(0, 5),
179
+ tracked: tracked.length === 0 ? [] : tracked.split(/\r?\n/),
180
+ };
181
+ }
182
+
183
+ async function fileSize(file) {
184
+ try {
185
+ return (await fs.stat(file)).size;
186
+ } catch (error) {
187
+ return 0;
188
+ }
189
+ }
190
+
191
+ async function directorySize(root, maxEntries) {
192
+ let total = 0;
193
+ let entries = 0;
194
+ const stack = [root];
195
+ while (stack.length > 0 && entries < maxEntries) {
196
+ const current = stack.pop();
197
+ let items;
198
+ try {
199
+ items = await fs.readdir(current, { withFileTypes: true });
200
+ } catch (error) {
201
+ continue;
202
+ }
203
+ for (const item of items) {
204
+ if (entries >= maxEntries) break;
205
+ entries += 1;
206
+ const full = path.join(current, item.name);
207
+ if (item.isDirectory()) {
208
+ if (SKIP_NAMES.has(item.name)) continue;
209
+ stack.push(full);
210
+ } else if (item.isFile()) {
211
+ total += await fileSize(full);
212
+ }
213
+ }
214
+ }
215
+ return { total, entries };
216
+ }
217
+
218
+ function isTextFile(name) {
219
+ 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);
220
+ }
221
+
222
+ function testFilePattern(name) {
223
+ return /(^|\/)(tests?\/|test_[^/]+\.py$|[^/]+\.(test|spec)\.(js|ts|jsx|tsx|mjs)$|.*_test\.(go|rs|py)$)/i.test(name);
224
+ }
225
+
226
+ async function scanWorkspace(workspace) {
227
+ const root = workspace.path;
228
+ const git = await gitInfo(root);
229
+ const tracked = Array.isArray(git.tracked) ? git.tracked : [];
230
+ const rootEntries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
231
+ const markers = {
232
+ readme: rootEntries.some((entry) => /^readme(\.|$)/i.test(entry.name)),
233
+ license: rootEntries.some((entry) => /^licen[cs]e(\.|$)/i.test(entry.name)),
234
+ gitignore: rootEntries.some((entry) => entry.name === '.gitignore'),
235
+ docker: rootEntries.some((entry) => /^(dockerfile|docker-compose\.ya?ml)$/i.test(entry.name)),
236
+ manifest: rootEntries.some((entry) => /^(package\.json|pyproject\.toml|requirements\.txt|cargo\.toml|go\.mod|pom\.xml|build\.gradle)$/i.test(entry.name)),
237
+ lockfile: rootEntries.some((entry) => /^(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|poetry\.lock|uv\.lock|cargo\.lock|go\.sum)$/i.test(entry.name)),
238
+ tests: rootEntries.some((entry) => /^tests?$/i.test(entry.name)),
239
+ ci: rootEntries.some((entry) => entry.name === '.github'),
240
+ docs: rootEntries.some((entry) => /^docs?$/i.test(entry.name)),
241
+ };
242
+ const artifacts = [];
243
+ for (const entry of rootEntries) {
244
+ if (entry.isDirectory() && ARTIFACT_NAMES.has(entry.name)) {
245
+ const size = await directorySize(path.join(root, entry.name), 5000);
246
+ artifacts.push({ name: entry.name, bytes: size.total });
247
+ }
248
+ }
249
+ const size = await directorySize(root, 20000);
250
+ const sourceFiles = tracked.filter((file) => isTextFile(file) && !file.includes('node_modules/')).slice(0, 400);
251
+ let todo = 0;
252
+ let dangerous = 0;
253
+ let secretFiles = [];
254
+ const readmeText = rootEntries.some((entry) => /^readme/i.test(entry.name)) ? await fs.readFile(path.join(root, rootEntries.find((entry) => /^readme/i.test(entry.name)).name), 'utf8').catch(() => '') : '';
255
+ for (const file of sourceFiles) {
256
+ const full = path.join(root, file);
257
+ const basename = path.basename(file).toLowerCase();
258
+ const sourceLike = /\.(py|js|mjs|cjs|ts|tsx|jsx|go|rs|c|h|cpp|java|kt|rb|php|sql)$/i.test(file);
259
+ const secretLike = !sourceLike && (
260
+ /(^|\/)\.env(\.|$)/i.test(file) ||
261
+ /\.(pem|key|p12|pfx|jks|keystore)$/i.test(file) ||
262
+ /(^|\/)(secrets?|credentials?)(\.(json|ya?ml|txt|ini|cfg)|$)/i.test(file) ||
263
+ basename === '.npmrc' || basename === '.pypirc'
264
+ );
265
+ if (secretLike) secretFiles.push(file);
266
+ if (!/\.(py|js|mjs|cjs|ts|tsx|jsx|sh|ps1|bat|cmd|yml|yaml)$/i.test(file)) continue;
267
+ const text = await fs.readFile(full, 'utf8').catch(() => '');
268
+ if (text.length === 0) continue;
269
+ todo += (text.match(/\b(TODO|FIXME|XXX|HACK)\b/g) || []).length;
270
+ dangerous += (text.match(/shell\s*=\s*True|\bexec\s*\(|pickle\.load\s*\(/g) || []).length;
271
+ }
272
+ const workflowsDir = path.join(root, '.github', 'workflows');
273
+ const workflowFiles = await fs.readdir(workflowsDir, { withFileTypes: true }).catch(() => []);
274
+ const workflows = [];
275
+ for (const file of workflowFiles.filter((entry) => entry.isFile()).slice(0, 30)) {
276
+ const text = await fs.readFile(path.join(workflowsDir, file.name), 'utf8').catch(() => '');
277
+ workflows.push({
278
+ name: file.name,
279
+ pullRequest: /pull_request/.test(text),
280
+ selfHosted: /self-hosted/.test(text),
281
+ permissions: /permissions:/.test(text),
282
+ publish: /npm publish|twine upload|upload-release|gh release|docker push/.test(text),
283
+ });
284
+ }
285
+ const testFiles = tracked.filter((file) => testFilePattern(file)).length;
286
+ const dimensions = {
287
+ documentation: (markers.readme ? 35 : 0) + (markers.docs ? 25 : 0) + (readmeText.length > 4000 ? 20 : readmeText.length > 1000 ? 10 : 0) + (markers.license ? 20 : 0),
288
+ development: (markers.manifest ? 25 : 0) + (markers.lockfile ? 20 : 0) + (markers.gitignore ? 20 : 0) + (markers.ci ? 25 : 0) + (markers.docker ? 10 : 0),
289
+ debugging: Math.min(100, testFiles * 6 + (markers.tests ? 20 : 0) + (markers.ci ? 20 : 0)),
290
+ performance: Math.max(0, 100 - Math.min(80, Math.round(size.total / (1024 * 1024 * 50) * 10)) - (artifacts.length > 0 ? 20 : 0)),
291
+ functionality: (markers.readme ? 30 : 0) + (tracked.length > 50 ? 35 : tracked.length > 10 ? 20 : 0) + (markers.tests ? 20 : 0) + (markers.ci ? 15 : 0),
292
+ 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) - (git.isRepo && !git.remote ? 10 : 0)),
293
+ github: (git.isRepo ? 30 : 0) + (git.remote ? 30 : 0) + (git.dirty === 0 ? 20 : 0) + (git.tags && git.tags.length > 0 ? 20 : 0),
294
+ };
295
+ const score = Math.round(Object.values(dimensions).reduce((sum, value) => sum + value, 0) / 7);
296
+ const risks = [];
297
+ if (!git.isRepo) risks.push({ severity: 'P2', text: '目录未初始化 Git,无法追踪变更、回滚或协作。' });
298
+ if (git.isRepo && git.dirty > 0) risks.push({ severity: 'P1', text: `工作区有 ${git.dirty} 个未提交变更,发布前需确认。` });
299
+ if (git.isRepo && !git.remote) risks.push({ severity: 'P2', text: '仓库没有 origin 远端,缺少异地备份/协作。' });
300
+ if (!markers.readme) risks.push({ severity: 'P1', text: '缺少 README,后来开发者难以启动项目。' });
301
+ if (!markers.ci) risks.push({ severity: 'P1', text: '缺少 CI,测试/构建容易回归。' });
302
+ if (workflows.some((wf) => wf.selfHosted && wf.pullRequest)) risks.push({ severity: 'P0', text: '自托管 Runner 被 pull_request 触发,公开仓库存在不可信代码执行风险。' });
303
+ if (secretFiles.length > 0) risks.push({ severity: 'P1', text: `发现疑似密钥文件:${secretFiles.slice(0, 5).join(', ')}(只检查了文件名,未读取内容)。` });
304
+ if (dangerous > 10) risks.push({ severity: 'P1', text: `发现 ${dangerous} 处 shell=True/exec/pickle.load 高危模式,需确认输入来源。` });
305
+ else if (dangerous > 0) risks.push({ severity: 'P2', text: `发现 ${dangerous} 处 shell=True/exec/pickle.load 模式,建议人工确认。` });
306
+ const significantArtifacts = artifacts.filter((item) => item.bytes >= 1024 * 1024);
307
+ if (significantArtifacts.length > 0) {
308
+ const huge = significantArtifacts.some((item) => item.bytes >= 1024 * 1024 * 1024);
309
+ risks.push({ severity: huge ? 'P1' : 'P2', text: `发现构建/缓存目录:${significantArtifacts.map((item) => item.name + ' ' + Math.round(item.bytes / 1024 / 1024) + 'MB').join(', ')}。` });
310
+ }
311
+ if (!markers.lockfile && markers.manifest) risks.push({ severity: 'P2', text: '有依赖清单但没有锁文件,构建不可复现。' });
312
+ if (todo > 20) risks.push({ severity: 'P2', text: `源码中约 ${todo} 处 TODO/FIXME,建议建立清理计划。` });
313
+ return {
314
+ path: root,
315
+ title: workspace.title,
316
+ git,
317
+ markers,
318
+ workflows,
319
+ size: { bytes: size.total, scannedEntries: size.entries, artifacts },
320
+ testFiles,
321
+ todo,
322
+ dangerous,
323
+ secretFiles,
324
+ dimensions,
325
+ score,
326
+ risks,
327
+ };
328
+ }
329
+
330
+ function grade(score) {
331
+ if (score >= 85) return 'A';
332
+ if (score >= 70) return 'B';
333
+ if (score >= 55) return 'C';
334
+ return 'D';
335
+ }
336
+
337
+ function renderReport(workspaces) {
338
+ const lines = ['# Workspace Audit Report', '', `Generated: ${new Date().toISOString()}`, ''];
339
+ for (const workspace of workspaces) {
340
+ 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}`, '');
341
+ lines.push('### Dimensions', '');
342
+ for (const [key, value] of Object.entries(workspace.dimensions)) lines.push(`- ${key}: ${value}/100`);
343
+ lines.push('', '### Risks', '');
344
+ if (workspace.risks.length === 0) lines.push('- No blocking risks found.');
345
+ for (const risk of workspace.risks) lines.push(`- **${risk.severity}** ${risk.text}`);
346
+ lines.push('');
347
+ }
348
+ return lines.join('\n');
349
+ }
350
+
351
+ async function runScan() {
352
+ const settings = await loadSettings();
353
+ const discovered = await discoverWorkspaces(settings);
354
+ const workspaces = [];
355
+ const failures = [];
356
+ for (const workspace of discovered) {
357
+ try {
358
+ workspaces.push(await scanWorkspace(workspace));
359
+ } catch (error) {
360
+ failures.push({ path: workspace.path, error: String((error && error.message) || error) });
361
+ }
362
+ }
363
+ workspaces.sort((left, right) => left.score - right.score);
364
+ return { generatedAt: Date.now(), workspaces, failures, discovered: discovered.length, report: renderReport(workspaces) };
365
+ }
366
+
367
+ function createSettingsRoute() {
368
+ return async (req, res) => {
369
+ if (!matchesPath(req, SETTINGS_PATH)) {
370
+ sendJson(res, 404, { error: 'not found' });
371
+ return;
372
+ }
373
+ if (req.method === 'GET') {
374
+ if (!requireToken(req, res)) return;
375
+ sendJson(res, 200, { settings: await loadSettings(), workspaces: await discoverWorkspaces(await loadSettings()) });
376
+ return;
377
+ }
378
+ if (req.method !== 'POST') {
379
+ sendJson(res, 405, { error: 'method not allowed' });
380
+ return;
381
+ }
382
+ if (!requireToken(req, res)) return;
383
+ try {
384
+ const body = await readJsonBody(req);
385
+ const current = await loadSettings();
386
+ const requested = [
387
+ ...(Array.isArray(body.roots) ? body.roots : []),
388
+ ...(typeof body.add === 'string' ? [body.add] : []),
389
+ ];
390
+ const roots = [...new Set([...current.roots, ...requested])]
391
+ .map((entry) => String(entry).trim())
392
+ .filter((entry) => entry.length > 0)
393
+ .slice(0, 30);
394
+ const next = { roots };
395
+ await saveSettings(next);
396
+ sendJson(res, 200, { ok: true, settings: next });
397
+ } catch (error) {
398
+ sendJson(res, 400, { error: 'invalid json' });
399
+ }
400
+ };
401
+ }
402
+
403
+ function apply(ctx) {
404
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: AUTH_PATH, handler: createAuthRoute() }), 'workspace-audit: auth');
405
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: SCAN_PATH, handler: createReadRoute(SCAN_PATH, () => runScan()) }), 'workspace-audit: scan');
406
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: SETTINGS_PATH, handler: createSettingsRoute() }), 'workspace-audit: settings');
407
+ }
408
+
409
+ export { apply };
410
+ export const inject = ['webServer'];
package/locale/en.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "panel": "Workspace Audit"
3
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "panel": "工作区体检"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@tomjerrygit/dsh-workspace-audit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Local-first workspace health audit for DeepSeek Harness: Git, tests, CI, size, security hotspots, and a Markdown handover report.",
6
+ "type": "module",
7
+ "engines": {
8
+ "dsh": ">=0.2.0-rc.2"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./client": "./client.js",
16
+ "./icon": "./icon.svg",
17
+ "./locale/*.json": "./locale/*.json"
18
+ },
19
+ "files": [
20
+ "index.js",
21
+ "client.js",
22
+ "cordis.patch.yml",
23
+ "icon.svg",
24
+ "locale/*.json",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "dsh": {
29
+ "manifestVersion": 1,
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ },
33
+ "client": {
34
+ "platform": "web",
35
+ "immediately": true,
36
+ "inject": [
37
+ "@deepseek-ai/dsh-client-ui-sidebar",
38
+ "@deepseek-ai/dsh-client-ui-layout",
39
+ "@deepseek-ai/dsh-client-locale"
40
+ ]
41
+ }
42
+ },
43
+ "author": {
44
+ "name": "TomJerryGit"
45
+ },
46
+ "license": "MIT",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/TomJerryGit/dsh-workspace-audit.git"
50
+ },
51
+ "homepage": "https://github.com/TomJerryGit/dsh-workspace-audit#readme",
52
+ "bugs": {
53
+ "url": "https://github.com/TomJerryGit/dsh-workspace-audit/issues"
54
+ },
55
+ "keywords": [
56
+ "dsh-plugin",
57
+ "deepseek-harness",
58
+ "workspace",
59
+ "audit",
60
+ "health",
61
+ "handover",
62
+ "security"
63
+ ]
64
+ }