dsh-d365-kit 0.0.0-stage → 0.34.3
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/CHANGELOG.md +318 -0
- package/CHANGELOG.zh-CN.md +317 -0
- package/INSTALL.md +94 -0
- package/INSTALL.zh-CN.md +93 -0
- package/LICENSE +21 -0
- package/README.md +335 -2
- package/README.zh-CN.md +336 -0
- package/client.js +1818 -0
- package/cordis.patch.yml +44 -0
- package/docs/en/architecture.md +172 -0
- package/docs/en/checks.md +184 -0
- package/docs/en/installation.md +151 -0
- package/docs/en/permissions.md +161 -0
- package/docs/en/scenarios.md +202 -0
- package/docs/en/tools.md +665 -0
- package/docs/en/troubleshooting.md +131 -0
- package/docs/zh/architecture.md +172 -0
- package/docs/zh/checks.md +184 -0
- package/docs/zh/installation.md +151 -0
- package/docs/zh/permissions.md +161 -0
- package/docs/zh/scenarios.md +202 -0
- package/docs/zh/tools.md +665 -0
- package/docs/zh/troubleshooting.md +131 -0
- package/examples/prompts.en.md +206 -0
- package/examples/prompts.zh-CN.md +206 -0
- package/examples/waivers.example.json +20 -0
- package/icon.svg +6 -0
- package/index.js +142 -0
- package/lib/auth.js +181 -0
- package/lib/client.js +496 -0
- package/lib/code-check.js +549 -0
- package/lib/config.js +323 -0
- package/lib/contract-history.js +49 -0
- package/lib/contract.js +583 -0
- package/lib/csharp-check.js +493 -0
- package/lib/data-migrate.js +1318 -0
- package/lib/data-policy.js +78 -0
- package/lib/error-codes.js +80 -0
- package/lib/errors.js +81 -0
- package/lib/gitlab.js +161 -0
- package/lib/health.js +205 -0
- package/lib/migrate.js +732 -0
- package/lib/preflight.js +116 -0
- package/lib/refs.js +191 -0
- package/lib/resources.js +320 -0
- package/lib/retention-defaults.js +17 -0
- package/lib/retention.js +384 -0
- package/lib/runtime.js +232 -0
- package/lib/settings-http.js +512 -0
- package/lib/settings-store.js +371 -0
- package/lib/skill.js +76 -0
- package/lib/snapshot.js +149 -0
- package/lib/tool-code-check.js +133 -0
- package/lib/tool-configure.js +169 -0
- package/lib/tool-contract.js +376 -0
- package/lib/tool-data-sync.js +559 -0
- package/lib/tool-data-write.js +203 -0
- package/lib/tool-data.js +282 -0
- package/lib/tool-diff.js +484 -0
- package/lib/tool-environments.js +106 -0
- package/lib/tool-flows.js +540 -0
- package/lib/tool-health.js +127 -0
- package/lib/tool-invoke.js +284 -0
- package/lib/tool-layers.js +497 -0
- package/lib/tool-metadata.js +417 -0
- package/lib/tool-migrate.js +102 -0
- package/lib/tool-refs.js +206 -0
- package/lib/tool-reports.js +299 -0
- package/lib/tool-sync.js +430 -0
- package/lib/tool-trace.js +420 -0
- package/lib/tool-write.js +563 -0
- package/lib/waivers.js +83 -0
- package/lib/write-actions.js +1374 -0
- package/lib/write-policy.js +199 -0
- package/lib/write-store.js +167 -0
- package/locale/en.json +68 -0
- package/locale/zh.json +68 -0
- package/package.json +59 -4
- package/skills/d365-troubleshooting/SKILL.md +115 -0
package/lib/tool-refs.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `d365_refs` — the reverse question: who uses this?
|
|
3
|
+
*
|
|
4
|
+
* `d365_metadata` answers "does it exist"; `d365_layers` answers "who overrides
|
|
5
|
+
* it"; this answers "what breaks if I remove or rename it". Every branch reads
|
|
6
|
+
* stored definitions (formxml, layoutxml/fetchxml, workflow clientdata) and
|
|
7
|
+
* reports exact matches, so the answer is evidence rather than a guess.
|
|
8
|
+
*/
|
|
9
|
+
import { loadConfig } from './config.js'
|
|
10
|
+
import { makeClient, pickEnvironments, writeArtifact } from './runtime.js'
|
|
11
|
+
import { D365Error } from './errors.js'
|
|
12
|
+
import {
|
|
13
|
+
findFieldAutomations,
|
|
14
|
+
findFieldForms,
|
|
15
|
+
findFieldViews,
|
|
16
|
+
findPluginUsage,
|
|
17
|
+
findResourceForms,
|
|
18
|
+
} from './refs.js'
|
|
19
|
+
|
|
20
|
+
const KINDS = ['field', 'web_resource', 'plugin_assembly']
|
|
21
|
+
|
|
22
|
+
export const definition = {
|
|
23
|
+
name: 'd365_refs',
|
|
24
|
+
description:
|
|
25
|
+
'反查引用:某个字段被哪些表单/视图/流程使用,某个 Web 资源被哪些表单加载,某个插件程序集有哪些注册类型与步骤。'
|
|
26
|
+
+ '用于判断"改这个/删这个会不会坏",与 d365_layers(谁覆盖)互补。可一次查多个环境。',
|
|
27
|
+
parameters: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
required: ['kind'],
|
|
31
|
+
properties: {
|
|
32
|
+
kind: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
enum: KINDS,
|
|
35
|
+
description: 'field:字段引用;web_resource:表单脚本引用;plugin_assembly:插件注册情况。',
|
|
36
|
+
},
|
|
37
|
+
entity: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'kind=field 时必填:字段所属实体逻辑名,例如 account。',
|
|
40
|
+
},
|
|
41
|
+
attribute: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'kind=field 时必填:字段逻辑名,例如 contoso_customertier。',
|
|
44
|
+
},
|
|
45
|
+
name: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'kind=web_resource 时填 Web 资源名称(如 contoso_/scripts/account_form.js);kind=plugin_assembly 时填程序集名(如 Contoso.Customizations)。',
|
|
48
|
+
},
|
|
49
|
+
environment: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: '环境 key,例如 dev。省略则用默认环境。',
|
|
52
|
+
},
|
|
53
|
+
environments: {
|
|
54
|
+
type: 'array',
|
|
55
|
+
items: { type: 'string' },
|
|
56
|
+
description: '一次查多个环境,例如 ["dev","sit"]。',
|
|
57
|
+
},
|
|
58
|
+
allEnvironments: {
|
|
59
|
+
type: 'boolean',
|
|
60
|
+
description: '对全部已配置环境执行同一查询。',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
output: {
|
|
65
|
+
schema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
required: ['text'],
|
|
69
|
+
properties: {
|
|
70
|
+
text: { type: 'string' },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
render(_args, value) {
|
|
74
|
+
return [{ type: 'text', text: value?.text ?? '' }]
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Render a field reference report for one environment. */
|
|
80
|
+
function renderField(env, entity, attribute, result) {
|
|
81
|
+
const lines = [`字段 ${entity}.${attribute} 的引用(${env.key})`]
|
|
82
|
+
const total = result.forms.length + result.views.length + result.automations.length
|
|
83
|
+
|
|
84
|
+
lines.push('', `表单:${result.forms.length} 张`)
|
|
85
|
+
for (const form of result.forms.slice(0, 10)) {
|
|
86
|
+
lines.push(`- ${form.name}(${form.typeLabel}${form.managed ? ',受管' : ''}${form.visible ? '' : ',字段被隐藏'})`)
|
|
87
|
+
}
|
|
88
|
+
if (result.forms.length > 10) lines.push(`- … 其余 ${result.forms.length - 10} 张见文件`)
|
|
89
|
+
|
|
90
|
+
const layout = result.views.filter((view) => view.inLayout).length
|
|
91
|
+
const filtered = result.views.filter((view) => view.inFilter).length
|
|
92
|
+
lines.push('', `视图:${result.views.length} 个(列 ${layout} / 筛选条件 ${filtered})`)
|
|
93
|
+
for (const view of result.views.slice(0, 10)) {
|
|
94
|
+
const usage = [view.inLayout ? '显示列' : null, view.inFilter ? '筛选条件' : null].filter(Boolean).join('+')
|
|
95
|
+
lines.push(`- ${view.name}(${usage}${view.default ? ',默认视图' : ''})`)
|
|
96
|
+
}
|
|
97
|
+
if (result.views.length > 10) lines.push(`- … 其余 ${result.views.length - 10} 个见文件`)
|
|
98
|
+
|
|
99
|
+
lines.push('', `流程/业务规则:${result.automations.length} 个`)
|
|
100
|
+
for (const item of result.automations.slice(0, 10)) {
|
|
101
|
+
lines.push(`- ${item.name}(${item.category},${item.state})`)
|
|
102
|
+
}
|
|
103
|
+
if (result.automations.length > 10) lines.push(`- … 其余 ${result.automations.length - 10} 个见文件`)
|
|
104
|
+
|
|
105
|
+
lines.push(
|
|
106
|
+
'',
|
|
107
|
+
total
|
|
108
|
+
? `结论:共 ${total} 处引用;删除或改名会同时影响上述位置。`
|
|
109
|
+
: '结论:没有找到引用。注意本工具按实体过滤表单,快速查看表单里引用其它实体字段的情况不在范围内。',
|
|
110
|
+
)
|
|
111
|
+
return lines.join('\n')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Render a web resource report for one environment. */
|
|
115
|
+
function renderResource(env, name, forms) {
|
|
116
|
+
const lines = [`Web 资源 ${name} 的引用(${env.key})`]
|
|
117
|
+
if (!forms.length) {
|
|
118
|
+
lines.push('', '结论:没有表单引用这个库。若它是 ribbon 命令或独立加载的脚本,这不代表它没在用。')
|
|
119
|
+
return lines.join('\n')
|
|
120
|
+
}
|
|
121
|
+
lines.push('', `表单:${forms.length} 张`)
|
|
122
|
+
let handlers = 0
|
|
123
|
+
for (const form of forms.slice(0, 12)) {
|
|
124
|
+
handlers += form.handlers.length
|
|
125
|
+
const names = form.handlers.map((h) => `${h.event || '?'}→${h.functionName || '?'}`).join(',')
|
|
126
|
+
lines.push(`- ${form.name}(${form.typeLabel},${form.entity})${names ? `:${names}` : ''}`)
|
|
127
|
+
}
|
|
128
|
+
if (forms.length > 12) lines.push(`- … 其余 ${forms.length - 12} 张见文件`)
|
|
129
|
+
lines.push('', `结论:${forms.length} 张表单加载该库,共 ${handlers} 个事件处理器;改函数名或删文件会让这些处理器失效。`)
|
|
130
|
+
return lines.join('\n')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Render a plugin assembly report for one environment. */
|
|
134
|
+
function renderAssembly(env, name, result) {
|
|
135
|
+
const { assembly, types } = result
|
|
136
|
+
const lines = [
|
|
137
|
+
`插件程序集 ${assembly.name} 的注册情况(${env.key})`,
|
|
138
|
+
`版本:${assembly.version ?? '未知'},隔离模式:${assembly.isolation ?? '?'}`,
|
|
139
|
+
`类型:${types.length} 个`,
|
|
140
|
+
]
|
|
141
|
+
let stepCount = 0
|
|
142
|
+
let disabled = 0
|
|
143
|
+
for (const type of types.slice(0, 12)) {
|
|
144
|
+
stepCount += type.steps.length
|
|
145
|
+
disabled += type.steps.filter((step) => !step.enabled).length
|
|
146
|
+
lines.push('', `- ${type.typename}`)
|
|
147
|
+
if (!type.steps.length) lines.push(' (没有注册步骤)')
|
|
148
|
+
for (const step of type.steps.slice(0, 8)) {
|
|
149
|
+
const filter = step.filteringAttributes ? `,过滤 ${step.filteringAttributes}` : ''
|
|
150
|
+
lines.push(` · ${step.name}(stage ${step.stage},mode ${step.mode},rank ${step.rank ?? 1}${filter}${step.enabled ? '' : ',已停用'})`)
|
|
151
|
+
}
|
|
152
|
+
if (type.steps.length > 8) lines.push(` · … 其余 ${type.steps.length - 8} 步见文件`)
|
|
153
|
+
}
|
|
154
|
+
if (types.length > 12) lines.push(`… 其余 ${types.length - 12} 个类型见文件`)
|
|
155
|
+
lines.push('', `结论:共 ${stepCount} 个步骤${disabled ? `,其中 ${disabled} 个已停用` : ''}。`)
|
|
156
|
+
return lines.join('\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function register(ctx, state) {
|
|
160
|
+
return ctx.tools.register({
|
|
161
|
+
...definition,
|
|
162
|
+
async execute(args, exec) {
|
|
163
|
+
const config = await loadConfig(state.rawConfig)
|
|
164
|
+
const kind = args.kind
|
|
165
|
+
if (!KINDS.includes(kind)) {
|
|
166
|
+
throw new D365Error(`不认识的查询类型:${kind}`, { code: 'unknown-kind', hint: `可选:${KINDS.join(' / ')}` })
|
|
167
|
+
}
|
|
168
|
+
if (kind === 'field' && (!args.entity || !args.attribute)) {
|
|
169
|
+
throw new D365Error('kind=field 需要 entity 和 attribute', { code: 'missing-field' })
|
|
170
|
+
}
|
|
171
|
+
if (kind !== 'field' && !args.name) {
|
|
172
|
+
throw new D365Error(`kind=${kind} 需要 name`, { code: 'missing-name' })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const envs = pickEnvironments(config, args)
|
|
176
|
+
const sections = []
|
|
177
|
+
const artifact = { kind, entity: args.entity, attribute: args.attribute, name: args.name, environments: {} }
|
|
178
|
+
|
|
179
|
+
for (const env of envs) {
|
|
180
|
+
const client = makeClient(env, config)
|
|
181
|
+
let text
|
|
182
|
+
if (kind === 'field') {
|
|
183
|
+
const result = {
|
|
184
|
+
forms: await findFieldForms(client, args.entity, args.attribute, exec?.signal),
|
|
185
|
+
views: await findFieldViews(client, args.entity, args.attribute, exec?.signal),
|
|
186
|
+
automations: await findFieldAutomations(client, args.entity, args.attribute, exec?.signal),
|
|
187
|
+
}
|
|
188
|
+
artifact.environments[env.key] = result
|
|
189
|
+
text = renderField(env, args.entity, args.attribute, result)
|
|
190
|
+
} else if (kind === 'web_resource') {
|
|
191
|
+
const forms = await findResourceForms(client, args.name, exec?.signal)
|
|
192
|
+
artifact.environments[env.key] = { forms }
|
|
193
|
+
text = renderResource(env, args.name, forms)
|
|
194
|
+
} else {
|
|
195
|
+
const result = await findPluginUsage(client, args.name, exec?.signal)
|
|
196
|
+
artifact.environments[env.key] = result
|
|
197
|
+
text = renderAssembly(env, args.name, result)
|
|
198
|
+
}
|
|
199
|
+
sections.push(text)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const file = await writeArtifact(config, `refs-${kind}`, artifact)
|
|
203
|
+
return { text: `${sections.join('\n\n')}\n\n完整结果:${file}` }
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `d365_reports` — one place to see what the other tools produced, how much
|
|
3
|
+
* space `.d365` is using, and to apply the retention policy by hand.
|
|
4
|
+
*
|
|
5
|
+
* Cleanup always plans first (`dryRun` defaults to true) and never touches
|
|
6
|
+
* waivers, pending plans, config or secrets.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
10
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
11
|
+
import { loadConfig } from './config.js'
|
|
12
|
+
import { D365Error } from './errors.js'
|
|
13
|
+
import { dataRoot } from './snapshot.js'
|
|
14
|
+
import { readHistory } from './contract-history.js'
|
|
15
|
+
import { readWaivers } from './waivers.js'
|
|
16
|
+
import {
|
|
17
|
+
applyCleanup, compactAllSnapshots, formatBytes, planCleanup, storageSummary,
|
|
18
|
+
} from './retention.js'
|
|
19
|
+
|
|
20
|
+
const CLASS_IDS = ['snapshots', 'reports', 'diffs', 'query', 'changes', 'all']
|
|
21
|
+
|
|
22
|
+
async function listFiles(directory, limit) {
|
|
23
|
+
let names = []
|
|
24
|
+
try {
|
|
25
|
+
names = await readdir(directory)
|
|
26
|
+
} catch {
|
|
27
|
+
return []
|
|
28
|
+
}
|
|
29
|
+
const rows = []
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
try {
|
|
32
|
+
const info = await stat(join(directory, name))
|
|
33
|
+
if (!info.isFile()) continue
|
|
34
|
+
rows.push({ name, size: info.size, modifiedAt: info.mtime.toISOString() })
|
|
35
|
+
} catch {
|
|
36
|
+
// A file that vanished between readdir and stat is simply not listed.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
rows.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
|
|
40
|
+
return rows.slice(0, limit)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const VIEW_DEFAULT_LIMIT = 60
|
|
44
|
+
const VIEW_MAX_LIMIT = 2000
|
|
45
|
+
const VIEW_WRAP_DEFAULT = 400
|
|
46
|
+
|
|
47
|
+
/** @returns true when `file` stays inside `root` (no `..` escape, no absolute jump). */
|
|
48
|
+
function insideRoot(root, file) {
|
|
49
|
+
const rel = relative(root, file)
|
|
50
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Locate an artifact the model asked to read.
|
|
55
|
+
*
|
|
56
|
+
* Only files under `.d365` are reachable: an absolute path that points
|
|
57
|
+
* elsewhere is refused rather than silently read, because the tool's contract
|
|
58
|
+
* is "show me what this plugin wrote", not "read any file".
|
|
59
|
+
* @returns `{ file, size }`
|
|
60
|
+
*/
|
|
61
|
+
async function resolveArtifact(config, name) {
|
|
62
|
+
const wanted = String(name ?? '').trim()
|
|
63
|
+
if (!wanted) {
|
|
64
|
+
throw new D365Error('view 需要 file', {
|
|
65
|
+
hint: '先用 action=list 看有哪些产物;file 可以是 .d365 下的相对路径(如 query/xxx.json)或文件名。',
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
const root = resolve(dataRoot(config))
|
|
69
|
+
const candidates = isAbsolute(wanted)
|
|
70
|
+
? [resolve(wanted)]
|
|
71
|
+
: [resolve(root, wanted), ...['query', 'reports', 'diffs', 'changes'].map((dir) => resolve(root, dir, wanted))]
|
|
72
|
+
for (const candidate of candidates) {
|
|
73
|
+
if (!insideRoot(root, candidate)) continue
|
|
74
|
+
try {
|
|
75
|
+
const info = await stat(candidate)
|
|
76
|
+
if (info.isFile()) return { file: candidate, size: info.size }
|
|
77
|
+
} catch {
|
|
78
|
+
// Not here; the next candidate may be.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
throw new D365Error(`找不到产物:${wanted}`, {
|
|
82
|
+
code: 'artifact-not-found',
|
|
83
|
+
hint: '只在 .d365 目录里查找。用 action=list 看现有文件;绝对路径也必须落在这个目录内。',
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Render a window of one artifact.
|
|
89
|
+
*
|
|
90
|
+
* JSON written by the query tools is one very long line, so it is expanded by
|
|
91
|
+
* default; `match` narrows to the lines that mention a string, which is the
|
|
92
|
+
* only practical way to find one GUID in a 500 KB result.
|
|
93
|
+
*/
|
|
94
|
+
async function renderView(config, args) {
|
|
95
|
+
const { file, size } = await resolveArtifact(config, args.file)
|
|
96
|
+
const raw = await readFile(file, 'utf8')
|
|
97
|
+
const isJson = file.toLowerCase().endsWith('.json')
|
|
98
|
+
const pretty = args.pretty ?? isJson
|
|
99
|
+
let text = raw
|
|
100
|
+
if (pretty && isJson) {
|
|
101
|
+
try {
|
|
102
|
+
text = JSON.stringify(JSON.parse(raw), null, 2)
|
|
103
|
+
} catch {
|
|
104
|
+
// Malformed or truncated JSON: show what is on disk instead of failing.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const allLines = text.split(/\r?\n/)
|
|
109
|
+
const match = String(args.match ?? '').trim()
|
|
110
|
+
const numbered = allLines.map((line, index) => ({ line, number: index + 1 }))
|
|
111
|
+
const filtered = match
|
|
112
|
+
? numbered.filter((item) => item.line.toLowerCase().includes(match.toLowerCase()))
|
|
113
|
+
: numbered
|
|
114
|
+
|
|
115
|
+
const offset = Number.isFinite(args.offset) && args.offset > 0 ? Math.floor(args.offset) : 0
|
|
116
|
+
const limit = Math.min(Math.max(Number.isFinite(args.limit) ? Math.floor(args.limit) : VIEW_DEFAULT_LIMIT, 1), VIEW_MAX_LIMIT)
|
|
117
|
+
const wrap = args.wrap === 0 ? 0 : Math.min(Math.max(Number.isFinite(args.wrap) ? Math.floor(args.wrap) : VIEW_WRAP_DEFAULT, 40), 4000)
|
|
118
|
+
const window = filtered.slice(offset, offset + limit)
|
|
119
|
+
|
|
120
|
+
const header = [
|
|
121
|
+
`文件:${file}`,
|
|
122
|
+
`大小:${formatBytes(size)} · 行数:${allLines.length}${pretty && isJson ? '(JSON 已展开)' : ''}${match ? ` · 匹配 "${match}":${filtered.length} 行` : ''}`,
|
|
123
|
+
]
|
|
124
|
+
const shownTo = offset + window.length
|
|
125
|
+
const remaining = filtered.length - shownTo
|
|
126
|
+
header.push(`第 ${offset + 1}–${shownTo} 行${remaining > 0 ? `(还有 ${remaining} 行,调 offset: ${shownTo} 继续)` : ''}`)
|
|
127
|
+
|
|
128
|
+
const body = []
|
|
129
|
+
for (const item of window) {
|
|
130
|
+
const pieces = wrap > 0 ? item.line.match(new RegExp(`[\\s\\S]{1,${wrap}}`, 'g')) ?? [''] : [item.line]
|
|
131
|
+
body.push(`${String(item.number).padStart(6)}│ ${pieces[0]}`)
|
|
132
|
+
for (const piece of pieces.slice(1)) body.push(`${' '.repeat(6)}│ ${piece}`)
|
|
133
|
+
}
|
|
134
|
+
if (!window.length) body.push(match ? '(没有匹配的行)' : '(文件为空)')
|
|
135
|
+
|
|
136
|
+
return { text: [...header, '', ...body].join('\n'), file }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function storageLines(summary) {
|
|
140
|
+
const lines = [`占用合计:${formatBytes(summary.totalBytes)}${summary.overLimit ? `(超过 ${summary.warnMegabytes} MB,建议清理)` : ''}`, '']
|
|
141
|
+
for (const row of summary.classes) {
|
|
142
|
+
lines.push(`- ${row.label}(${row.id}):${row.count} 个 · ${formatBytes(row.bytes)} · 保留 ${row.keep} 个 / ${row.maxAgeDays} 天${row.hint ? ` · ${row.hint}` : ''}`)
|
|
143
|
+
}
|
|
144
|
+
return lines
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function planLines(plan) {
|
|
148
|
+
const lines = [`将删除 ${plan.totalFiles} 个文件,释放 ${formatBytes(plan.totalBytes)}`, '']
|
|
149
|
+
for (const row of plan.classes) {
|
|
150
|
+
if (!row.remove.length) {
|
|
151
|
+
lines.push(`- ${row.label}:无需清理(${row.count} 个,保留 ${row.keep} 个 / ${row.maxAgeDays} 天)`)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
lines.push(`- ${row.label}:删除 ${row.remove.length} 个 · ${formatBytes(row.bytes)}`)
|
|
155
|
+
for (const item of row.remove.slice(0, 5)) {
|
|
156
|
+
lines.push(` · ${item.name}(${item.ageDays} 天前,${formatBytes(item.bytes)})`)
|
|
157
|
+
}
|
|
158
|
+
if (row.remove.length > 5) lines.push(` · … 其余 ${row.remove.length - 5} 个`)
|
|
159
|
+
}
|
|
160
|
+
return lines
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function register(ctx, state) {
|
|
164
|
+
const dispose = ctx.tools.register({
|
|
165
|
+
name: 'd365_reports',
|
|
166
|
+
description:
|
|
167
|
+
'查看本机数据占用与产物(.d365/reports、query 等),并按保留策略清理或压缩历史产物。cleanup 默认只预演(dryRun),确认后传 dryRun=false 才真正删除。',
|
|
168
|
+
parameters: {
|
|
169
|
+
type: 'object',
|
|
170
|
+
additionalProperties: false,
|
|
171
|
+
properties: {
|
|
172
|
+
action: {
|
|
173
|
+
type: 'string',
|
|
174
|
+
enum: ['list', 'cleanup', 'compact', 'view'],
|
|
175
|
+
description: 'list:看占用与产物;view:读回一个产物文件(分页/过滤);cleanup:按策略清理;compact:把已有快照压成紧凑 JSON。默认 list。',
|
|
176
|
+
},
|
|
177
|
+
file: {
|
|
178
|
+
type: 'string',
|
|
179
|
+
description: 'view 的文件:.d365 下的相对路径(如 query/2026-11-06T01-36-47-274Z-layers.json)或文件名。',
|
|
180
|
+
},
|
|
181
|
+
offset: { type: 'number', description: 'view 从第几行开始(0 基),默认 0。' },
|
|
182
|
+
match: { type: 'string', description: 'view 只显示包含这个字符串的行(不区分大小写)。在大 JSON 里找一条 GUID 用这个。' },
|
|
183
|
+
wrap: { type: 'number', description: 'view 每行最多多少字符,超长折行;0 表示不折行。默认 400。' },
|
|
184
|
+
pretty: { type: 'boolean', description: 'view 是否把 JSON 缩进展开,默认对 .json 为 true。' },
|
|
185
|
+
target: {
|
|
186
|
+
type: 'string',
|
|
187
|
+
enum: CLASS_IDS,
|
|
188
|
+
description: 'cleanup 的范围:某一类或 all,默认 all。',
|
|
189
|
+
},
|
|
190
|
+
dryRun: {
|
|
191
|
+
type: 'boolean',
|
|
192
|
+
description: 'cleanup 默认 true,只预演不删除;确认要删时传 false。',
|
|
193
|
+
},
|
|
194
|
+
limit: { type: 'number', description: 'list 每类最多列多少条,默认 15。' },
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
output: {
|
|
198
|
+
schema: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
additionalProperties: false,
|
|
201
|
+
required: ['schemaVersion', 'action', 'text'],
|
|
202
|
+
properties: {
|
|
203
|
+
schemaVersion: { type: 'number' },
|
|
204
|
+
action: { type: 'string' },
|
|
205
|
+
text: { type: 'string' },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
render: (args, value) => [{ type: 'text', text: value.text }],
|
|
209
|
+
},
|
|
210
|
+
async execute(args) {
|
|
211
|
+
const config = await loadConfig(state.rawConfig)
|
|
212
|
+
const action = args.action ?? 'list'
|
|
213
|
+
|
|
214
|
+
if (action === 'view') {
|
|
215
|
+
const view = await renderView(config, args)
|
|
216
|
+
return { schemaVersion: 1, action, text: view.text }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (action === 'compact') {
|
|
220
|
+
const result = await compactAllSnapshots(config)
|
|
221
|
+
const summary = await storageSummary(config)
|
|
222
|
+
const lines = [
|
|
223
|
+
`已压缩 ${result.snapshots} 份快照里的 ${result.files} 个文件,释放 ${formatBytes(result.savedBytes)}。`,
|
|
224
|
+
'',
|
|
225
|
+
...storageLines(summary),
|
|
226
|
+
]
|
|
227
|
+
return { schemaVersion: 1, action, text: lines.join('\n') }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (action === 'cleanup') {
|
|
231
|
+
const target = CLASS_IDS.includes(args.target) ? args.target : 'all'
|
|
232
|
+
const dryRun = args.dryRun !== false
|
|
233
|
+
const plan = await planCleanup(config, { target })
|
|
234
|
+
if (dryRun) {
|
|
235
|
+
const lines = [`清理预演(${target}):尚未删除任何文件。`, '', ...planLines(plan)]
|
|
236
|
+
lines.push('', '确认要删除时,把 dryRun 设为 false 再调用一次。')
|
|
237
|
+
return { schemaVersion: 1, action, text: lines.join('\n') }
|
|
238
|
+
}
|
|
239
|
+
const applied = await applyCleanup(config, plan)
|
|
240
|
+
const summary = await storageSummary(config)
|
|
241
|
+
const lines = [
|
|
242
|
+
`已删除 ${applied.deleted.length} 个文件,释放 ${formatBytes(applied.bytes)}。`,
|
|
243
|
+
...(applied.failures.length ? ['', `失败 ${applied.failures.length} 个:`, ...applied.failures.map((item) => `- ${item.name}:${item.reason}`)] : []),
|
|
244
|
+
'',
|
|
245
|
+
...storageLines(summary),
|
|
246
|
+
]
|
|
247
|
+
return { schemaVersion: 1, action, text: lines.join('\n') }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const limit = Number.isFinite(args.limit) ? Math.min(Math.max(args.limit, 1), 100) : 15
|
|
251
|
+
const root = dataRoot(config)
|
|
252
|
+
const reports = await listFiles(join(root, 'reports'), limit)
|
|
253
|
+
const queries = await listFiles(join(root, 'query'), limit)
|
|
254
|
+
const summary = await storageSummary(config)
|
|
255
|
+
|
|
256
|
+
let waiverCount = 0
|
|
257
|
+
try {
|
|
258
|
+
const document = await readWaivers(config)
|
|
259
|
+
waiverCount = (document.waivers ?? []).length
|
|
260
|
+
} catch {
|
|
261
|
+
waiverCount = 0
|
|
262
|
+
}
|
|
263
|
+
let lastContractRun = ''
|
|
264
|
+
try {
|
|
265
|
+
const history = await readHistory(config)
|
|
266
|
+
const last = history.runs?.[history.runs.length - 1]
|
|
267
|
+
if (last) {
|
|
268
|
+
lastContractRun = `${last.at}(${last.source} → ${(last.targets ?? []).join(', ')},阻断 ${last.blocker ?? 0} / 警告 ${last.warning ?? 0},豁免 ${last.waived ?? 0})`
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
lastContractRun = ''
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const lines = ['本机数据', '', ...storageLines(summary), '']
|
|
275
|
+
// Totals come from the storage summary, not from the truncated listing:
|
|
276
|
+
// "25 个" next to three bullets looked like a bug.
|
|
277
|
+
const classCount = (id) => summary.classes.find((item) => item.id === id)?.count ?? 0
|
|
278
|
+
const reportTotal = classCount('reports')
|
|
279
|
+
lines.push(`报告(.d365/reports):${reportTotal} 个${reportTotal > reports.length ? `,下面是最近 ${reports.length} 个` : ''}`)
|
|
280
|
+
for (const row of reports) {
|
|
281
|
+
lines.push(`- ${row.name} · ${(row.size / 1024).toFixed(1)} KB · ${row.modifiedAt}`)
|
|
282
|
+
}
|
|
283
|
+
lines.push('')
|
|
284
|
+
const queryTotal = classCount('query')
|
|
285
|
+
const shownQueries = queries.slice(0, 8)
|
|
286
|
+
lines.push(`查询留证(.d365/query):${queryTotal} 个${queryTotal > shownQueries.length ? `,下面是最近 ${shownQueries.length} 个` : ''}`)
|
|
287
|
+
for (const row of shownQueries) {
|
|
288
|
+
lines.push(`- ${row.name} · ${(row.size / 1024).toFixed(1)} KB`)
|
|
289
|
+
}
|
|
290
|
+
lines.push('')
|
|
291
|
+
lines.push(`已登记豁免:${waiverCount} 条`)
|
|
292
|
+
lines.push(`最近一次契约检查:${lastContractRun || '还没有跑过'}`)
|
|
293
|
+
lines.push('')
|
|
294
|
+
lines.push('要读回某个产物(而不是只看文件名),用 action=view 加 file;大文件配 match 过滤、offset/limit 翻页。')
|
|
295
|
+
return { schemaVersion: 1, action: 'list', text: lines.join('\n') }
|
|
296
|
+
},
|
|
297
|
+
})
|
|
298
|
+
return dispose
|
|
299
|
+
}
|