dsh-d365-kit 0.34.1 → 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 -308
- package/CHANGELOG.zh-CN.md +317 -307
- package/INSTALL.md +94 -94
- package/INSTALL.zh-CN.md +93 -93
- package/LICENSE +21 -21
- package/README.md +336 -327
- package/README.zh-CN.md +336 -327
- package/client.js +1818 -1818
- package/cordis.patch.yml +44 -44
- package/docs/en/architecture.md +172 -172
- package/docs/en/checks.md +184 -184
- package/docs/en/installation.md +151 -151
- package/docs/en/permissions.md +161 -161
- package/docs/en/scenarios.md +202 -202
- package/docs/en/tools.md +665 -665
- package/docs/en/troubleshooting.md +131 -131
- package/docs/zh/architecture.md +172 -172
- package/docs/zh/checks.md +184 -184
- package/docs/zh/installation.md +151 -151
- package/docs/zh/permissions.md +161 -161
- package/docs/zh/scenarios.md +202 -202
- package/docs/zh/tools.md +665 -665
- package/docs/zh/troubleshooting.md +131 -131
- package/examples/prompts.en.md +206 -206
- package/examples/prompts.zh-CN.md +206 -206
- package/examples/waivers.example.json +20 -20
- package/icon.svg +6 -6
- package/index.js +139 -139
- package/lib/auth.js +181 -181
- package/lib/client.js +496 -496
- package/lib/code-check.js +549 -549
- package/lib/config.js +323 -323
- package/lib/contract-history.js +49 -49
- package/lib/contract.js +583 -583
- package/lib/csharp-check.js +493 -493
- package/lib/data-migrate.js +1318 -1318
- package/lib/data-policy.js +78 -78
- package/lib/error-codes.js +80 -80
- package/lib/gitlab.js +161 -161
- package/lib/health.js +205 -205
- package/lib/migrate.js +732 -732
- package/lib/preflight.js +116 -116
- package/lib/refs.js +191 -191
- package/lib/resources.js +320 -320
- package/lib/retention-defaults.js +17 -17
- package/lib/retention.js +384 -384
- package/lib/runtime.js +232 -232
- package/lib/settings-http.js +512 -512
- package/lib/settings-store.js +371 -371
- package/lib/snapshot.js +149 -149
- package/lib/tool-code-check.js +133 -133
- package/lib/tool-configure.js +169 -169
- package/lib/tool-contract.js +376 -376
- package/lib/tool-data-sync.js +559 -559
- package/lib/tool-data-write.js +203 -203
- package/lib/tool-data.js +282 -282
- package/lib/tool-diff.js +484 -484
- package/lib/tool-environments.js +106 -106
- package/lib/tool-flows.js +540 -540
- package/lib/tool-health.js +127 -127
- package/lib/tool-layers.js +497 -497
- package/lib/tool-metadata.js +417 -417
- package/lib/tool-migrate.js +102 -102
- package/lib/tool-refs.js +206 -206
- package/lib/tool-reports.js +299 -299
- package/lib/tool-sync.js +430 -430
- package/lib/tool-write.js +563 -563
- package/lib/waivers.js +83 -83
- package/lib/write-actions.js +1374 -1374
- package/lib/write-policy.js +199 -199
- package/lib/write-store.js +167 -167
- package/locale/en.json +68 -68
- package/locale/zh.json +68 -68
- package/package.json +2 -2
- package/skills/d365-troubleshooting/SKILL.md +115 -115
package/lib/runtime.js
CHANGED
|
@@ -1,232 +1,232 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared plumbing for the metadata tools: environment selection, workspace
|
|
3
|
-
* artifact writing, and the plain-text formatting used in tool results.
|
|
4
|
-
*
|
|
5
|
-
* Every tool result is "summary + path": the model gets a short, readable
|
|
6
|
-
* digest, and the complete data lands in a file it can read in full when the
|
|
7
|
-
* question needs it. That keeps results small without losing evidence.
|
|
8
|
-
*/
|
|
9
|
-
import { mkdir, writeFile } from 'node:fs/promises'
|
|
10
|
-
import { join, resolve } from 'node:path'
|
|
11
|
-
import { DataverseClient } from './client.js'
|
|
12
|
-
import { D365Error } from './errors.js'
|
|
13
|
-
import { credentialStatus, dataDirectory } from './config.js'
|
|
14
|
-
import { KEY_FIELDS, COMPARE_FIELDS, rowKey } from './resources.js'
|
|
15
|
-
|
|
16
|
-
/** Rows kept in the inline digest before the rest is left to the artifact. */
|
|
17
|
-
export const INLINE_ROWS = 12
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Pick and validate the environments a call targets.
|
|
21
|
-
* @param config - normalized config.
|
|
22
|
-
* @param args - tool arguments (`environment`, `environments`, `allEnvironments`).
|
|
23
|
-
* @returns the ordered list of environments to query.
|
|
24
|
-
*/
|
|
25
|
-
export function pickEnvironments(config, args = {}) {
|
|
26
|
-
const available = config.environments
|
|
27
|
-
const keys = Object.keys(available)
|
|
28
|
-
if (!keys.length) {
|
|
29
|
-
throw new D365Error('还没有配置任何 D365 环境', {
|
|
30
|
-
code: 'no-environments',
|
|
31
|
-
hint: '在插件配置的 environments 下加入环境(url + 凭据环境变量名),见 cordis.patch.yml 的注释示例。',
|
|
32
|
-
})
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (args.allEnvironments) return keys.map((key) => available[key])
|
|
36
|
-
|
|
37
|
-
const requested = []
|
|
38
|
-
if (Array.isArray(args.environments)) requested.push(...args.environments)
|
|
39
|
-
if (args.environment) requested.push(args.environment)
|
|
40
|
-
|
|
41
|
-
const unique = [...new Set(requested.filter((key) => typeof key === 'string' && key.trim()))]
|
|
42
|
-
if (!unique.length) {
|
|
43
|
-
const fallback = config.defaultEnvironment
|
|
44
|
-
if (!fallback) throw new D365Error('未指定环境,且没有默认环境', { code: 'no-default' })
|
|
45
|
-
return [available[fallback]]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const missing = unique.filter((key) => !available[key])
|
|
49
|
-
if (missing.length) {
|
|
50
|
-
throw new D365Error(`未知环境:${missing.join(', ')}`, {
|
|
51
|
-
code: 'unknown-environment',
|
|
52
|
-
hint: `可用环境:${keys.join(', ')}`,
|
|
53
|
-
})
|
|
54
|
-
}
|
|
55
|
-
return unique.map((key) => available[key])
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Create a client for one environment.
|
|
60
|
-
* @param env - normalized environment.
|
|
61
|
-
* @param config - normalized config (for timeout).
|
|
62
|
-
* @returns a read-only client.
|
|
63
|
-
*/
|
|
64
|
-
export function makeClient(env, config) {
|
|
65
|
-
return new DataverseClient(env, { timeoutMs: config.timeoutMs })
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Read the organization record plus the platform version.
|
|
70
|
-
*
|
|
71
|
-
* No `$select`: real deployments reject columns this plugin assumed were
|
|
72
|
-
* universal (`version`, then `organizationstructure` — both 0x80060888 "Could
|
|
73
|
-
* not find a property named ..."), and an unfiltered row is the only shape that
|
|
74
|
-
* works everywhere. The record is optional — the version endpoint alone proves
|
|
75
|
-
* the connection — so a rejected organization read is not fatal on its own.
|
|
76
|
-
* @returns `{ ...organizationProperties, version? }`
|
|
77
|
-
*/
|
|
78
|
-
export async function readOrganization(client, signal) {
|
|
79
|
-
let record = {}
|
|
80
|
-
let failure
|
|
81
|
-
try {
|
|
82
|
-
const page = await client.getPaged('organizations', {}, 1, signal)
|
|
83
|
-
record = page.records[0] ?? {}
|
|
84
|
-
} catch (error) {
|
|
85
|
-
failure = error
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const version = await client.readVersion(signal)
|
|
89
|
-
if (!Object.keys(record).length && !version && failure) throw failure
|
|
90
|
-
return { ...record, version }
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Report environments and whether they can authenticate, without calling the
|
|
95
|
-
* network unless `check` is set.
|
|
96
|
-
* @returns a printable status report.
|
|
97
|
-
*/
|
|
98
|
-
export async function environmentStatus(config, options = {}) {
|
|
99
|
-
const report = []
|
|
100
|
-
for (const env of Object.values(config.environments)) {
|
|
101
|
-
const status = credentialStatus(env)
|
|
102
|
-
const row = {
|
|
103
|
-
env,
|
|
104
|
-
...status,
|
|
105
|
-
reachable: undefined,
|
|
106
|
-
error: undefined,
|
|
107
|
-
}
|
|
108
|
-
if (options.check) {
|
|
109
|
-
try {
|
|
110
|
-
const client = makeClient(env, config)
|
|
111
|
-
row.organization = await readOrganization(client, options.signal)
|
|
112
|
-
row.reachable = true
|
|
113
|
-
} catch (error) {
|
|
114
|
-
row.reachable = false
|
|
115
|
-
row.error = error.message
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
report.push(row)
|
|
119
|
-
}
|
|
120
|
-
return report
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Resolve the directory artifacts are written to. */
|
|
124
|
-
export function artifactRoot(config) {
|
|
125
|
-
return join(dataDirectory(config), '.d365', 'query')
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Write one artifact and return its absolute path.
|
|
130
|
-
*
|
|
131
|
-
* Absolute matters: the artifact lives under the config directory (usually
|
|
132
|
-
* `~/Documents/deepseek-harness/...`), while the plugin's cwd is the profile
|
|
133
|
-
* directory. A path relative to that cwd (`../../../Documents/...`) cannot be
|
|
134
|
-
* opened from the session workspace the model actually reads files in.
|
|
135
|
-
* @param name - filename without extension.
|
|
136
|
-
* @param payload - JSON-serialisable data.
|
|
137
|
-
*/
|
|
138
|
-
export async function writeArtifact(config, name, payload) {
|
|
139
|
-
const root = artifactRoot(config)
|
|
140
|
-
await mkdir(root, { recursive: true })
|
|
141
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
142
|
-
const file = join(root, `${stamp}-${name.replace(/[^a-zA-Z0-9._-]+/g, '-')}.json`)
|
|
143
|
-
await writeFile(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
|
144
|
-
return resolve(file).replace(/\\/g, '/')
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Truncate a cell so one wide field cannot blow up the digest. */
|
|
148
|
-
function cell(value, width = 40) {
|
|
149
|
-
if (value === undefined || value === null) return ''
|
|
150
|
-
const text = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
151
|
-
return text.length > width ? `${text.slice(0, width - 1)}…` : text
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Render rows as a fixed-width markdown table; omitted columns are counted.
|
|
156
|
-
* @param rows - records already flattened.
|
|
157
|
-
* @param columns - column names in display order.
|
|
158
|
-
*/
|
|
159
|
-
export function formatTable(rows, columns, options = {}) {
|
|
160
|
-
const width = options.width ?? 40
|
|
161
|
-
const limit = options.limit ?? INLINE_ROWS
|
|
162
|
-
const shown = rows.slice(0, limit)
|
|
163
|
-
const header = `| ${columns.map((c) => cell(c, 24)).join(' | ')} |`
|
|
164
|
-
const divider = `| ${columns.map(() => '---').join(' | ')} |`
|
|
165
|
-
const body = shown.map((row) => `| ${columns.map((c) => cell(row?.[c], width)).join(' | ')} |`)
|
|
166
|
-
const lines = [header, divider, ...body]
|
|
167
|
-
if (rows.length > shown.length) lines.push(`| … 其余 ${rows.length - shown.length} 行见文件 |`)
|
|
168
|
-
return lines.join('\n')
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Compare rows from several environments and describe what differs.
|
|
173
|
-
* Presence is always compared; a few resources also compare meaningful fields.
|
|
174
|
-
* @param resource - resource key, for key/compare field lookup.
|
|
175
|
-
* @param perEnv - array of `{ env, rows }`.
|
|
176
|
-
* @returns `{ lines, missing, extra, changed }` where `lines` is printable text.
|
|
177
|
-
*/
|
|
178
|
-
export function compareEnvironments(resource, perEnv) {
|
|
179
|
-
if (perEnv.length < 2) return null
|
|
180
|
-
|
|
181
|
-
const keysByEnv = new Map()
|
|
182
|
-
const rowsByEnv = new Map()
|
|
183
|
-
for (const { env, rows } of perEnv) {
|
|
184
|
-
const map = new Map()
|
|
185
|
-
for (const row of rows) map.set(rowKey(resource, row), row)
|
|
186
|
-
keysByEnv.set(env.key, map)
|
|
187
|
-
rowsByEnv.set(env.key, rows)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const envKeys = perEnv.map((entry) => entry.env.key)
|
|
191
|
-
const allKeys = [...new Set(perEnv.flatMap((entry) => [...keysByEnv.get(entry.env.key).keys()]))]
|
|
192
|
-
|
|
193
|
-
const onlyIn = new Map()
|
|
194
|
-
const changed = []
|
|
195
|
-
const compareFields = COMPARE_FIELDS[resource] ?? []
|
|
196
|
-
|
|
197
|
-
for (const key of allKeys) {
|
|
198
|
-
const present = envKeys.filter((envKey) => keysByEnv.get(envKey).has(key))
|
|
199
|
-
if (present.length !== envKeys.length) {
|
|
200
|
-
const missingIn = envKeys.filter((envKey) => !present.includes(envKey))
|
|
201
|
-
const label = `${key} 仅存在于 ${present.join('/')},缺失于 ${missingIn.join('/')}`
|
|
202
|
-
onlyIn.set(key, { present, missingIn, label })
|
|
203
|
-
}
|
|
204
|
-
if (compareFields.length && present.length > 1) {
|
|
205
|
-
const diffs = []
|
|
206
|
-
for (const field of compareFields) {
|
|
207
|
-
const values = present.map((envKey) => String(keysByEnv.get(envKey).get(key)?.[field] ?? ''))
|
|
208
|
-
if (new Set(values).size > 1) {
|
|
209
|
-
diffs.push(`${field}: ${present.map((envKey, i) => `${envKey}=${values[i] || '∅'}`).join(' / ')}`)
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
if (diffs.length) changed.push(`${key} — ${diffs.join(';')}`)
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const lines = []
|
|
217
|
-
if (!onlyIn.size && !changed.length) {
|
|
218
|
-
lines.push(`跨环境对比(${envKeys.join(' / ')}):按 ${KEY_FIELDS[resource] ?? '名称'} 对齐后无差异。`)
|
|
219
|
-
} else {
|
|
220
|
-
if (onlyIn.size) {
|
|
221
|
-
lines.push(`跨环境差异:${onlyIn.size} 个对象的环境分布不一致`)
|
|
222
|
-
for (const entry of [...onlyIn.values()].slice(0, 15)) lines.push(`- ${entry.label}`)
|
|
223
|
-
if (onlyIn.size > 15) lines.push(`- … 其余 ${onlyIn.size - 15} 条见文件`)
|
|
224
|
-
}
|
|
225
|
-
if (changed.length) {
|
|
226
|
-
lines.push(`字段值差异:${changed.length} 个对象`)
|
|
227
|
-
for (const line of changed.slice(0, 15)) lines.push(`- ${line}`)
|
|
228
|
-
if (changed.length > 15) lines.push(`- … 其余 ${changed.length - 15} 条见文件`)
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return { lines, onlyIn, changed, allKeys }
|
|
232
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the metadata tools: environment selection, workspace
|
|
3
|
+
* artifact writing, and the plain-text formatting used in tool results.
|
|
4
|
+
*
|
|
5
|
+
* Every tool result is "summary + path": the model gets a short, readable
|
|
6
|
+
* digest, and the complete data lands in a file it can read in full when the
|
|
7
|
+
* question needs it. That keeps results small without losing evidence.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { join, resolve } from 'node:path'
|
|
11
|
+
import { DataverseClient } from './client.js'
|
|
12
|
+
import { D365Error } from './errors.js'
|
|
13
|
+
import { credentialStatus, dataDirectory } from './config.js'
|
|
14
|
+
import { KEY_FIELDS, COMPARE_FIELDS, rowKey } from './resources.js'
|
|
15
|
+
|
|
16
|
+
/** Rows kept in the inline digest before the rest is left to the artifact. */
|
|
17
|
+
export const INLINE_ROWS = 12
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pick and validate the environments a call targets.
|
|
21
|
+
* @param config - normalized config.
|
|
22
|
+
* @param args - tool arguments (`environment`, `environments`, `allEnvironments`).
|
|
23
|
+
* @returns the ordered list of environments to query.
|
|
24
|
+
*/
|
|
25
|
+
export function pickEnvironments(config, args = {}) {
|
|
26
|
+
const available = config.environments
|
|
27
|
+
const keys = Object.keys(available)
|
|
28
|
+
if (!keys.length) {
|
|
29
|
+
throw new D365Error('还没有配置任何 D365 环境', {
|
|
30
|
+
code: 'no-environments',
|
|
31
|
+
hint: '在插件配置的 environments 下加入环境(url + 凭据环境变量名),见 cordis.patch.yml 的注释示例。',
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (args.allEnvironments) return keys.map((key) => available[key])
|
|
36
|
+
|
|
37
|
+
const requested = []
|
|
38
|
+
if (Array.isArray(args.environments)) requested.push(...args.environments)
|
|
39
|
+
if (args.environment) requested.push(args.environment)
|
|
40
|
+
|
|
41
|
+
const unique = [...new Set(requested.filter((key) => typeof key === 'string' && key.trim()))]
|
|
42
|
+
if (!unique.length) {
|
|
43
|
+
const fallback = config.defaultEnvironment
|
|
44
|
+
if (!fallback) throw new D365Error('未指定环境,且没有默认环境', { code: 'no-default' })
|
|
45
|
+
return [available[fallback]]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const missing = unique.filter((key) => !available[key])
|
|
49
|
+
if (missing.length) {
|
|
50
|
+
throw new D365Error(`未知环境:${missing.join(', ')}`, {
|
|
51
|
+
code: 'unknown-environment',
|
|
52
|
+
hint: `可用环境:${keys.join(', ')}`,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
return unique.map((key) => available[key])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create a client for one environment.
|
|
60
|
+
* @param env - normalized environment.
|
|
61
|
+
* @param config - normalized config (for timeout).
|
|
62
|
+
* @returns a read-only client.
|
|
63
|
+
*/
|
|
64
|
+
export function makeClient(env, config) {
|
|
65
|
+
return new DataverseClient(env, { timeoutMs: config.timeoutMs })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the organization record plus the platform version.
|
|
70
|
+
*
|
|
71
|
+
* No `$select`: real deployments reject columns this plugin assumed were
|
|
72
|
+
* universal (`version`, then `organizationstructure` — both 0x80060888 "Could
|
|
73
|
+
* not find a property named ..."), and an unfiltered row is the only shape that
|
|
74
|
+
* works everywhere. The record is optional — the version endpoint alone proves
|
|
75
|
+
* the connection — so a rejected organization read is not fatal on its own.
|
|
76
|
+
* @returns `{ ...organizationProperties, version? }`
|
|
77
|
+
*/
|
|
78
|
+
export async function readOrganization(client, signal) {
|
|
79
|
+
let record = {}
|
|
80
|
+
let failure
|
|
81
|
+
try {
|
|
82
|
+
const page = await client.getPaged('organizations', {}, 1, signal)
|
|
83
|
+
record = page.records[0] ?? {}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
failure = error
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const version = await client.readVersion(signal)
|
|
89
|
+
if (!Object.keys(record).length && !version && failure) throw failure
|
|
90
|
+
return { ...record, version }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Report environments and whether they can authenticate, without calling the
|
|
95
|
+
* network unless `check` is set.
|
|
96
|
+
* @returns a printable status report.
|
|
97
|
+
*/
|
|
98
|
+
export async function environmentStatus(config, options = {}) {
|
|
99
|
+
const report = []
|
|
100
|
+
for (const env of Object.values(config.environments)) {
|
|
101
|
+
const status = credentialStatus(env)
|
|
102
|
+
const row = {
|
|
103
|
+
env,
|
|
104
|
+
...status,
|
|
105
|
+
reachable: undefined,
|
|
106
|
+
error: undefined,
|
|
107
|
+
}
|
|
108
|
+
if (options.check) {
|
|
109
|
+
try {
|
|
110
|
+
const client = makeClient(env, config)
|
|
111
|
+
row.organization = await readOrganization(client, options.signal)
|
|
112
|
+
row.reachable = true
|
|
113
|
+
} catch (error) {
|
|
114
|
+
row.reachable = false
|
|
115
|
+
row.error = error.message
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
report.push(row)
|
|
119
|
+
}
|
|
120
|
+
return report
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Resolve the directory artifacts are written to. */
|
|
124
|
+
export function artifactRoot(config) {
|
|
125
|
+
return join(dataDirectory(config), '.d365', 'query')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Write one artifact and return its absolute path.
|
|
130
|
+
*
|
|
131
|
+
* Absolute matters: the artifact lives under the config directory (usually
|
|
132
|
+
* `~/Documents/deepseek-harness/...`), while the plugin's cwd is the profile
|
|
133
|
+
* directory. A path relative to that cwd (`../../../Documents/...`) cannot be
|
|
134
|
+
* opened from the session workspace the model actually reads files in.
|
|
135
|
+
* @param name - filename without extension.
|
|
136
|
+
* @param payload - JSON-serialisable data.
|
|
137
|
+
*/
|
|
138
|
+
export async function writeArtifact(config, name, payload) {
|
|
139
|
+
const root = artifactRoot(config)
|
|
140
|
+
await mkdir(root, { recursive: true })
|
|
141
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
142
|
+
const file = join(root, `${stamp}-${name.replace(/[^a-zA-Z0-9._-]+/g, '-')}.json`)
|
|
143
|
+
await writeFile(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
|
144
|
+
return resolve(file).replace(/\\/g, '/')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Truncate a cell so one wide field cannot blow up the digest. */
|
|
148
|
+
function cell(value, width = 40) {
|
|
149
|
+
if (value === undefined || value === null) return ''
|
|
150
|
+
const text = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
151
|
+
return text.length > width ? `${text.slice(0, width - 1)}…` : text
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Render rows as a fixed-width markdown table; omitted columns are counted.
|
|
156
|
+
* @param rows - records already flattened.
|
|
157
|
+
* @param columns - column names in display order.
|
|
158
|
+
*/
|
|
159
|
+
export function formatTable(rows, columns, options = {}) {
|
|
160
|
+
const width = options.width ?? 40
|
|
161
|
+
const limit = options.limit ?? INLINE_ROWS
|
|
162
|
+
const shown = rows.slice(0, limit)
|
|
163
|
+
const header = `| ${columns.map((c) => cell(c, 24)).join(' | ')} |`
|
|
164
|
+
const divider = `| ${columns.map(() => '---').join(' | ')} |`
|
|
165
|
+
const body = shown.map((row) => `| ${columns.map((c) => cell(row?.[c], width)).join(' | ')} |`)
|
|
166
|
+
const lines = [header, divider, ...body]
|
|
167
|
+
if (rows.length > shown.length) lines.push(`| … 其余 ${rows.length - shown.length} 行见文件 |`)
|
|
168
|
+
return lines.join('\n')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Compare rows from several environments and describe what differs.
|
|
173
|
+
* Presence is always compared; a few resources also compare meaningful fields.
|
|
174
|
+
* @param resource - resource key, for key/compare field lookup.
|
|
175
|
+
* @param perEnv - array of `{ env, rows }`.
|
|
176
|
+
* @returns `{ lines, missing, extra, changed }` where `lines` is printable text.
|
|
177
|
+
*/
|
|
178
|
+
export function compareEnvironments(resource, perEnv) {
|
|
179
|
+
if (perEnv.length < 2) return null
|
|
180
|
+
|
|
181
|
+
const keysByEnv = new Map()
|
|
182
|
+
const rowsByEnv = new Map()
|
|
183
|
+
for (const { env, rows } of perEnv) {
|
|
184
|
+
const map = new Map()
|
|
185
|
+
for (const row of rows) map.set(rowKey(resource, row), row)
|
|
186
|
+
keysByEnv.set(env.key, map)
|
|
187
|
+
rowsByEnv.set(env.key, rows)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const envKeys = perEnv.map((entry) => entry.env.key)
|
|
191
|
+
const allKeys = [...new Set(perEnv.flatMap((entry) => [...keysByEnv.get(entry.env.key).keys()]))]
|
|
192
|
+
|
|
193
|
+
const onlyIn = new Map()
|
|
194
|
+
const changed = []
|
|
195
|
+
const compareFields = COMPARE_FIELDS[resource] ?? []
|
|
196
|
+
|
|
197
|
+
for (const key of allKeys) {
|
|
198
|
+
const present = envKeys.filter((envKey) => keysByEnv.get(envKey).has(key))
|
|
199
|
+
if (present.length !== envKeys.length) {
|
|
200
|
+
const missingIn = envKeys.filter((envKey) => !present.includes(envKey))
|
|
201
|
+
const label = `${key} 仅存在于 ${present.join('/')},缺失于 ${missingIn.join('/')}`
|
|
202
|
+
onlyIn.set(key, { present, missingIn, label })
|
|
203
|
+
}
|
|
204
|
+
if (compareFields.length && present.length > 1) {
|
|
205
|
+
const diffs = []
|
|
206
|
+
for (const field of compareFields) {
|
|
207
|
+
const values = present.map((envKey) => String(keysByEnv.get(envKey).get(key)?.[field] ?? ''))
|
|
208
|
+
if (new Set(values).size > 1) {
|
|
209
|
+
diffs.push(`${field}: ${present.map((envKey, i) => `${envKey}=${values[i] || '∅'}`).join(' / ')}`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (diffs.length) changed.push(`${key} — ${diffs.join(';')}`)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const lines = []
|
|
217
|
+
if (!onlyIn.size && !changed.length) {
|
|
218
|
+
lines.push(`跨环境对比(${envKeys.join(' / ')}):按 ${KEY_FIELDS[resource] ?? '名称'} 对齐后无差异。`)
|
|
219
|
+
} else {
|
|
220
|
+
if (onlyIn.size) {
|
|
221
|
+
lines.push(`跨环境差异:${onlyIn.size} 个对象的环境分布不一致`)
|
|
222
|
+
for (const entry of [...onlyIn.values()].slice(0, 15)) lines.push(`- ${entry.label}`)
|
|
223
|
+
if (onlyIn.size > 15) lines.push(`- … 其余 ${onlyIn.size - 15} 条见文件`)
|
|
224
|
+
}
|
|
225
|
+
if (changed.length) {
|
|
226
|
+
lines.push(`字段值差异:${changed.length} 个对象`)
|
|
227
|
+
for (const line of changed.slice(0, 15)) lines.push(`- ${line}`)
|
|
228
|
+
if (changed.length > 15) lines.push(`- … 其余 ${changed.length - 15} 条见文件`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { lines, onlyIn, changed, allKeys }
|
|
232
|
+
}
|