dsh-d365-kit 0.34.1 → 0.34.4

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +319 -308
  2. package/CHANGELOG.zh-CN.md +318 -307
  3. package/INSTALL.md +94 -94
  4. package/INSTALL.zh-CN.md +93 -93
  5. package/LICENSE +21 -21
  6. package/README.md +336 -327
  7. package/README.zh-CN.md +336 -327
  8. package/client.js +1818 -1818
  9. package/cordis.patch.yml +44 -44
  10. package/docs/en/architecture.md +172 -172
  11. package/docs/en/checks.md +184 -184
  12. package/docs/en/installation.md +151 -151
  13. package/docs/en/permissions.md +161 -161
  14. package/docs/en/scenarios.md +202 -202
  15. package/docs/en/tools.md +665 -665
  16. package/docs/en/troubleshooting.md +131 -131
  17. package/docs/zh/architecture.md +172 -172
  18. package/docs/zh/checks.md +184 -184
  19. package/docs/zh/installation.md +151 -151
  20. package/docs/zh/permissions.md +161 -161
  21. package/docs/zh/scenarios.md +202 -202
  22. package/docs/zh/tools.md +665 -665
  23. package/docs/zh/troubleshooting.md +131 -131
  24. package/examples/prompts.en.md +206 -206
  25. package/examples/prompts.zh-CN.md +206 -206
  26. package/examples/waivers.example.json +20 -20
  27. package/icon.svg +6 -6
  28. package/index.js +139 -139
  29. package/lib/auth.js +181 -181
  30. package/lib/client.js +496 -496
  31. package/lib/code-check.js +549 -549
  32. package/lib/config.js +323 -323
  33. package/lib/contract-history.js +49 -49
  34. package/lib/contract.js +583 -583
  35. package/lib/csharp-check.js +493 -493
  36. package/lib/data-migrate.js +1318 -1318
  37. package/lib/data-policy.js +78 -78
  38. package/lib/error-codes.js +80 -80
  39. package/lib/gitlab.js +161 -161
  40. package/lib/health.js +205 -205
  41. package/lib/migrate.js +732 -732
  42. package/lib/preflight.js +116 -116
  43. package/lib/refs.js +191 -191
  44. package/lib/resources.js +320 -320
  45. package/lib/retention-defaults.js +17 -17
  46. package/lib/retention.js +384 -384
  47. package/lib/runtime.js +232 -232
  48. package/lib/settings-http.js +512 -512
  49. package/lib/settings-store.js +371 -371
  50. package/lib/snapshot.js +149 -149
  51. package/lib/tool-code-check.js +133 -133
  52. package/lib/tool-configure.js +169 -169
  53. package/lib/tool-contract.js +376 -376
  54. package/lib/tool-data-sync.js +559 -559
  55. package/lib/tool-data-write.js +203 -203
  56. package/lib/tool-data.js +282 -282
  57. package/lib/tool-diff.js +484 -484
  58. package/lib/tool-environments.js +106 -106
  59. package/lib/tool-flows.js +540 -540
  60. package/lib/tool-health.js +127 -127
  61. package/lib/tool-layers.js +497 -497
  62. package/lib/tool-metadata.js +417 -417
  63. package/lib/tool-migrate.js +102 -102
  64. package/lib/tool-refs.js +206 -206
  65. package/lib/tool-reports.js +299 -299
  66. package/lib/tool-sync.js +430 -430
  67. package/lib/tool-write.js +563 -563
  68. package/lib/waivers.js +83 -83
  69. package/lib/write-actions.js +1374 -1374
  70. package/lib/write-policy.js +199 -199
  71. package/lib/write-store.js +167 -167
  72. package/locale/en.json +68 -68
  73. package/locale/zh.json +68 -68
  74. package/package.json +2 -2
  75. package/skills/d365-troubleshooting/SKILL.md +115 -115
@@ -1,371 +1,371 @@
1
- /**
2
- * Reading and writing the D365 environment list.
3
- *
4
- * One operation, two callers: the settings page in the Web UI and the
5
- * `d365_configure` tool both go through these functions, so the UI can never
6
- * drift from what the agent writes.
7
- *
8
- * Split of storage:
9
- * - `d365.config.json` — url, label, cloud, tenantId, clientId, default env.
10
- * - `d365.secrets.json` — client secrets typed into the form.
11
- *
12
- * The split exists so the file a team commits to Git never carries a secret.
13
- * The secrets file is plain text: it is protected by living outside the
14
- * committed config and by a tightened mode where the filesystem supports one
15
- * (Node's `chmod` is a no-op for permissions on Windows).
16
- */
17
- import { randomUUID } from 'node:crypto'
18
- import { chmod, readFile, rename, unlink, writeFile } from 'node:fs/promises'
19
- import { dirname, join } from 'node:path'
20
- import { credentialStatus, dataDirectory, loadConfig, readGitlabToken, SECRETS_FILENAME } from './config.js'
21
- import { readOrganization } from './runtime.js'
22
- import { DataverseClient } from './client.js'
23
- import { D365Error } from './errors.js'
24
-
25
- const KNOWN_CLOUDS = ['public', 'usgovernment', 'dod', 'china']
26
-
27
- /** Read one JSON file, returning `fallback` when it is missing or unreadable. */
28
- async function readJson(path, fallback) {
29
- try {
30
- return JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''))
31
- } catch {
32
- return fallback
33
- }
34
- }
35
-
36
- /** Write JSON through a temp file so a crashed write cannot truncate config. */
37
- async function writeJson(path, value, mode) {
38
- // A pid-only name makes two saves in the same process share one temp file, so
39
- // concurrent saves could rename each other's half-written bytes into place.
40
- const temp = `${path}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`
41
- await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
42
- if (mode) await chmod(temp, mode)
43
- await rename(temp, path)
44
- }
45
-
46
- /** One environment as the settings page shows it — never includes a secret. */
47
- function describeEnvironment(env) {
48
- const status = credentialStatus(env)
49
- const secret = status.credentials.clientSecret
50
- return {
51
- key: env.key,
52
- label: env.label,
53
- url: env.url,
54
- cloud: env.cloud,
55
- note: env.note ?? '',
56
- tenantId: env.tenantId ?? '',
57
- tenantIdEnv: env.tenantIdEnv ?? '',
58
- clientId: env.clientId ?? '',
59
- clientIdEnv: env.clientIdEnv ?? '',
60
- hasSecret: secret.ok,
61
- secretSource: secret.ok ? secret.source : undefined,
62
- secretMissing: secret.ok ? [] : secret.missing,
63
- complete: status.complete,
64
- missing: Object.entries(status.credentials)
65
- .filter(([, value]) => !value.ok)
66
- .map(([kind, value]) => ({ kind, missing: value.missing })),
67
- }
68
- }
69
-
70
- /** The whole settings surface, safe to send to a browser. */
71
- export async function readSettings(rawConfig) {
72
- const config = await loadConfig(rawConfig)
73
- return {
74
- configFile: config.configFile ?? null,
75
- secretsFile: config.secretsPath ?? join(dirname(config.configFile ?? '.'), SECRETS_FILENAME),
76
- defaultEnvironment: config.defaultEnvironment ?? null,
77
- issues: config.issues,
78
- clouds: KNOWN_CLOUDS,
79
- // Tool parameters that used to be patch-only. They are not per-environment,
80
- // so they live beside the environment list in the same config file.
81
- general: {
82
- snapshotKeep: config.snapshotKeep,
83
- maxRecords: config.maxRecords,
84
- timeoutMs: config.timeoutMs,
85
- dataDirectory: dataDirectory(config),
86
- },
87
- environments: Object.values(config.environments).map(describeEnvironment),
88
- // Code-repository access used by the diff-driven code check. The token is
89
- // reported as presence only: the browser never receives the value.
90
- gitlab: await describeGitlab(config),
91
- }
92
- }
93
-
94
- /** GitLab settings as the page shows them, including whether a token exists. */
95
- async function describeGitlab(config) {
96
- const token = await readGitlabToken(config)
97
- return {
98
- url: config.gitlab?.url ?? '',
99
- defaultProject: config.gitlab?.defaultProject ?? '',
100
- hasToken: token.ok,
101
- tokenSource: token.ok ? token.source : undefined,
102
- tokenMissing: token.ok ? [] : token.missing,
103
- }
104
- }
105
-
106
- /** Bounds for the general numbers, so a typo cannot disable pruning or timeouts. */
107
- const GENERAL_LIMITS = {
108
- snapshotKeep: { min: 1, max: 50, label: '快照保留份数' },
109
- maxRecords: { min: 1, max: 100000, label: '单次返回行数上限' },
110
- timeoutMs: { min: 5000, max: 300000, label: '请求超时(毫秒)' },
111
- }
112
-
113
- /** Copy the validated general numbers from `incoming` into `target`. */
114
- function applyGeneral(target, incoming) {
115
- for (const [key, limit] of Object.entries(GENERAL_LIMITS)) {
116
- if (!(key in (incoming ?? {}))) continue
117
- const value = Number(incoming[key])
118
- if (!Number.isInteger(value) || value < limit.min || value > limit.max) {
119
- throw new D365Error(`${limit.label}必须是 ${limit.min}–${limit.max} 之间的整数`, {
120
- code: 'invalid-setting',
121
- hint: `收到的是 ${JSON.stringify(incoming[key])}`,
122
- })
123
- }
124
- target[key] = value
125
- }
126
- return target
127
- }
128
-
129
- /**
130
- * Persist tool parameters without touching the environment list.
131
- *
132
- * Separate from `saveSettings` on purpose: a page section that only edits
133
- * "keep N snapshots" must never be able to rewrite (or accidentally drop) the
134
- * environments it did not load.
135
- */
136
- export async function saveGeneral(rawConfig, general) {
137
- const current = await loadConfig(rawConfig)
138
- if (!current.configFile) {
139
- throw new D365Error('插件没有配置 configFile,无法保存', {
140
- code: 'no-config-file',
141
- hint: '在 cordis.patch.yml 的 config.configFile 指定 d365.config.json 的绝对路径。',
142
- })
143
- }
144
- const file = await readJson(current.configFile, {})
145
- applyGeneral(file, general)
146
- await writeJson(current.configFile, file)
147
- return readSettings(rawConfig)
148
- }
149
-
150
- /**
151
- * Persist the GitLab block: URL and default project in the config file, the
152
- * access token in the secrets file.
153
- *
154
- * Kept separate from `saveSettings` for the same reason as `saveGeneral`: a
155
- * form that only edits the repository settings must not rewrite environments.
156
- */
157
- export async function saveGitlab(rawConfig, input) {
158
- const current = await loadConfig(rawConfig)
159
- if (!current.configFile) {
160
- throw new D365Error('插件没有配置 configFile,无法保存', {
161
- code: 'no-config-file',
162
- hint: '在 cordis.patch.yml 的 config.configFile 指定 d365.config.json 的绝对路径。',
163
- })
164
- }
165
-
166
- const file = await readJson(current.configFile, {})
167
- const url = String(input?.url ?? '').trim().replace(/\/+$/, '')
168
- const project = String(input?.defaultProject ?? '').trim().replace(/^\/+|\/+$/g, '')
169
-
170
- if (url && !/^https:\/\/[^\s/]+/i.test(url)) {
171
- throw new D365Error('GitLab 地址必须以 https:// 开头', {
172
- code: 'invalid-gitlab',
173
- hint: `收到的是 ${JSON.stringify(input?.url ?? '')}`,
174
- })
175
- }
176
- if (project && !/^[\w.-]+(\/[\w.-]+)+$/.test(project)) {
177
- throw new D365Error('默认项目要写成 group/project 的形式', {
178
- code: 'invalid-gitlab',
179
- hint: `收到的是 ${JSON.stringify(project)}`,
180
- })
181
- }
182
-
183
- const gitlab = { ...(file.gitlab ?? {}) }
184
- // Only fields the caller actually sent are touched: clearing the token must
185
- // not silently drop the address and project it belongs to.
186
- if (input?.url !== undefined) {
187
- if (url) gitlab.url = url
188
- else delete gitlab.url
189
- }
190
- if (input?.defaultProject !== undefined) {
191
- if (project) gitlab.defaultProject = project
192
- else delete gitlab.defaultProject
193
- }
194
- if (Object.keys(gitlab).length) file.gitlab = gitlab
195
- else delete file.gitlab
196
- await writeJson(current.configFile, file)
197
-
198
- const secretsPath = current.secretsPath ?? join(dirname(current.configFile), SECRETS_FILENAME)
199
- const secrets = await readJson(secretsPath, {})
200
- const token = typeof input?.token === 'string' ? input.token.trim() : ''
201
- if (token) secrets.gitlabToken = token
202
- else if (input?.clearToken) delete secrets.gitlabToken
203
-
204
- if (Object.keys(secrets).length) await writeJson(secretsPath, secrets, 0o600)
205
- else await unlink(secretsPath).catch(() => {})
206
- if (token) await appendGitignore(dirname(current.configFile))
207
-
208
- return readSettings(rawConfig)
209
- }
210
-
211
- /** Reject a row the UI could have caught, with a message naming the row. */
212
- function validateRow(key, row) {
213
- const problems = []
214
- const url = String(row?.url ?? '').trim().replace(/\/+$/, '')
215
- if (!url) problems.push('缺少 URL')
216
- else if (!/^https:\/\/[^\s/]+/i.test(url)) problems.push('URL 必须以 https:// 开头')
217
- const cloud = String(row?.cloud ?? 'public').trim() || 'public'
218
- if (!KNOWN_CLOUDS.includes(cloud)) problems.push(`cloud 只能是 ${KNOWN_CLOUDS.join(' / ')}`)
219
- if (problems.length) {
220
- throw new D365Error(`环境 "${key}" 配置有误:${problems.join(';')}`, {
221
- code: 'invalid-environment',
222
- hint: '例如 https://contoso-dev.crm5.dynamics.com',
223
- })
224
- }
225
- return { url, cloud }
226
- }
227
-
228
- /**
229
- * Persist the environment list.
230
- *
231
- * @param rawConfig - the plugin's patch config (supplies `configFile`).
232
- * @param payload - `{ defaultEnvironment, environments: { key: row } }` where a
233
- * row may carry `clientSecret` (write it) or `clearSecret` (remove it).
234
- * @returns the new settings view plus what changed.
235
- */
236
- export async function saveSettings(rawConfig, payload) {
237
- const current = await loadConfig(rawConfig)
238
- if (!current.configFile) {
239
- throw new D365Error('插件没有配置 configFile,无法保存', {
240
- code: 'no-config-file',
241
- hint: '在 cordis.patch.yml 的 config.configFile 指定 d365.config.json 的绝对路径。',
242
- })
243
- }
244
-
245
- const incoming = payload?.environments && typeof payload.environments === 'object' ? payload.environments : {}
246
- const existing = await readJson(current.configFile, {})
247
-
248
- const environments = {}
249
- for (const [rawKey, rawRow] of Object.entries(incoming)) {
250
- const key = String(rawKey).trim()
251
- if (!key) continue
252
- const { url, cloud } = validateRow(key, rawRow)
253
- const previous = existing.environments?.[key] ?? {}
254
- const row = { ...previous, url }
255
- row.cloud = cloud
256
- row.label = String(rawRow?.label ?? '').trim() || previous.label || key
257
- // Empty optional strings are removed rather than stored: a stray "" in the
258
- // file would win over the environment-variable fallback for that value.
259
- // A field the request does not mention at all is left untouched — otherwise
260
- // any client that sends a partial row (the settings page never had boxes for
261
- // tenantIdEnv / clientIdEnv / note) would silently delete them.
262
- const mentions = rawRow && typeof rawRow === 'object' ? rawRow : {}
263
- for (const field of ['tenantId', 'tenantIdEnv', 'clientId', 'clientIdEnv', 'note']) {
264
- if (!(field in mentions)) continue
265
- const value = String(mentions[field] ?? '').trim()
266
- if (value) row[field] = value
267
- else delete row[field]
268
- }
269
- if (cloud === 'public') delete row.cloud
270
- environments[key] = row
271
- }
272
-
273
- const keys = Object.keys(environments)
274
- const wantedDefault = String(payload?.defaultEnvironment ?? '').trim()
275
- const defaultEnvironment = keys.includes(wantedDefault)
276
- ? wantedDefault
277
- : (keys.includes(existing.defaultEnvironment) ? existing.defaultEnvironment : keys[0])
278
-
279
- const nextConfig = { ...existing, environments }
280
- if (defaultEnvironment) nextConfig.defaultEnvironment = defaultEnvironment
281
- else delete nextConfig.defaultEnvironment
282
-
283
- await writeJson(current.configFile, nextConfig)
284
-
285
- // Secrets: only rows that sent one, plus explicit removals.
286
- const secretsPath = current.secretsPath ?? join(dirname(current.configFile), SECRETS_FILENAME)
287
- const secrets = await readJson(secretsPath, {})
288
- const changedSecrets = []
289
- for (const [rawKey, rawRow] of Object.entries(incoming)) {
290
- const key = String(rawKey).trim()
291
- if (!key || !environments[key]) continue
292
- const secret = typeof rawRow?.clientSecret === 'string' ? rawRow.clientSecret.trim() : ''
293
- if (secret) {
294
- secrets[key] = { ...(secrets[key] ?? {}), clientSecret: secret }
295
- changedSecrets.push(key)
296
- } else if (rawRow?.clearSecret) {
297
- if (secrets[key]) {
298
- delete secrets[key].clientSecret
299
- if (!Object.keys(secrets[key]).length) delete secrets[key]
300
- changedSecrets.push(key)
301
- }
302
- }
303
- }
304
- // A removed environment loses its secret too, so re-adding the key cannot
305
- // silently inherit the old credential.
306
- for (const key of Object.keys(secrets)) {
307
- // `gitlabToken` is not tied to an environment; it must survive an
308
- // environment removal.
309
- if (key === 'gitlabToken') continue
310
- if (!environments[key]) {
311
- delete secrets[key]
312
- changedSecrets.push(key)
313
- }
314
- }
315
-
316
- if (Object.keys(secrets).length) await writeJson(secretsPath, secrets, 0o600)
317
- else await unlink(secretsPath).catch(() => {})
318
-
319
- if (changedSecrets.length) await appendGitignore(dirname(current.configFile))
320
-
321
- // Reload through the normal path so the token cache and the credential
322
- // overlay see the new values immediately.
323
- const view = await readSettings(rawConfig)
324
- return { settings: view, saved: true, secretsTouched: [...new Set(changedSecrets)] }
325
- }
326
-
327
- /** Keep the secrets file out of Git when the project already has a .gitignore. */
328
- async function appendGitignore(directory) {
329
- const path = join(directory, '.gitignore')
330
- try {
331
- const text = await readFile(path, 'utf8')
332
- if (text.split(/\r?\n/).some((line) => line.trim() === SECRETS_FILENAME)) return
333
- const join1 = text.endsWith('\n') ? '' : '\n'
334
- await writeFile(path, `${text}${join1}\n# D365 client secrets typed into the settings page\n${SECRETS_FILENAME}\n`, 'utf8')
335
- } catch {
336
- // No .gitignore here: nothing to protect, and creating one uninvited would
337
- // be surprising. The settings page says where the file lives.
338
- }
339
- }
340
-
341
- /**
342
- * Prove one environment can authenticate and read metadata.
343
- * @returns `{ ok, organization?, error?, hint?, missing? }` — never throws, so
344
- * the settings page can show the failure next to the row.
345
- */
346
- export async function checkEnvironment(rawConfig, key) {
347
- const config = await loadConfig(rawConfig)
348
- const env = config.environments[key]
349
- if (!env) {
350
- return { ok: false, error: `没有名为 ${key} 的环境`, missing: [] }
351
- }
352
-
353
- const status = credentialStatus(env)
354
- if (!status.complete) {
355
- return {
356
- ok: false,
357
- error: '凭据不全,无法连接',
358
- missing: Object.entries(status.credentials)
359
- .filter(([, value]) => !value.ok)
360
- .map(([kind, value]) => ({ kind, missing: value.missing })),
361
- }
362
- }
363
-
364
- try {
365
- const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
366
- const organization = await readOrganization(client)
367
- return { ok: true, organization }
368
- } catch (error) {
369
- return { ok: false, error: error.message, hint: error.hint }
370
- }
371
- }
1
+ /**
2
+ * Reading and writing the D365 environment list.
3
+ *
4
+ * One operation, two callers: the settings page in the Web UI and the
5
+ * `d365_configure` tool both go through these functions, so the UI can never
6
+ * drift from what the agent writes.
7
+ *
8
+ * Split of storage:
9
+ * - `d365.config.json` — url, label, cloud, tenantId, clientId, default env.
10
+ * - `d365.secrets.json` — client secrets typed into the form.
11
+ *
12
+ * The split exists so the file a team commits to Git never carries a secret.
13
+ * The secrets file is plain text: it is protected by living outside the
14
+ * committed config and by a tightened mode where the filesystem supports one
15
+ * (Node's `chmod` is a no-op for permissions on Windows).
16
+ */
17
+ import { randomUUID } from 'node:crypto'
18
+ import { chmod, readFile, rename, unlink, writeFile } from 'node:fs/promises'
19
+ import { dirname, join } from 'node:path'
20
+ import { credentialStatus, dataDirectory, loadConfig, readGitlabToken, SECRETS_FILENAME } from './config.js'
21
+ import { readOrganization } from './runtime.js'
22
+ import { DataverseClient } from './client.js'
23
+ import { D365Error } from './errors.js'
24
+
25
+ const KNOWN_CLOUDS = ['public', 'usgovernment', 'dod', 'china']
26
+
27
+ /** Read one JSON file, returning `fallback` when it is missing or unreadable. */
28
+ async function readJson(path, fallback) {
29
+ try {
30
+ return JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''))
31
+ } catch {
32
+ return fallback
33
+ }
34
+ }
35
+
36
+ /** Write JSON through a temp file so a crashed write cannot truncate config. */
37
+ async function writeJson(path, value, mode) {
38
+ // A pid-only name makes two saves in the same process share one temp file, so
39
+ // concurrent saves could rename each other's half-written bytes into place.
40
+ const temp = `${path}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`
41
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
42
+ if (mode) await chmod(temp, mode)
43
+ await rename(temp, path)
44
+ }
45
+
46
+ /** One environment as the settings page shows it — never includes a secret. */
47
+ function describeEnvironment(env) {
48
+ const status = credentialStatus(env)
49
+ const secret = status.credentials.clientSecret
50
+ return {
51
+ key: env.key,
52
+ label: env.label,
53
+ url: env.url,
54
+ cloud: env.cloud,
55
+ note: env.note ?? '',
56
+ tenantId: env.tenantId ?? '',
57
+ tenantIdEnv: env.tenantIdEnv ?? '',
58
+ clientId: env.clientId ?? '',
59
+ clientIdEnv: env.clientIdEnv ?? '',
60
+ hasSecret: secret.ok,
61
+ secretSource: secret.ok ? secret.source : undefined,
62
+ secretMissing: secret.ok ? [] : secret.missing,
63
+ complete: status.complete,
64
+ missing: Object.entries(status.credentials)
65
+ .filter(([, value]) => !value.ok)
66
+ .map(([kind, value]) => ({ kind, missing: value.missing })),
67
+ }
68
+ }
69
+
70
+ /** The whole settings surface, safe to send to a browser. */
71
+ export async function readSettings(rawConfig) {
72
+ const config = await loadConfig(rawConfig)
73
+ return {
74
+ configFile: config.configFile ?? null,
75
+ secretsFile: config.secretsPath ?? join(dirname(config.configFile ?? '.'), SECRETS_FILENAME),
76
+ defaultEnvironment: config.defaultEnvironment ?? null,
77
+ issues: config.issues,
78
+ clouds: KNOWN_CLOUDS,
79
+ // Tool parameters that used to be patch-only. They are not per-environment,
80
+ // so they live beside the environment list in the same config file.
81
+ general: {
82
+ snapshotKeep: config.snapshotKeep,
83
+ maxRecords: config.maxRecords,
84
+ timeoutMs: config.timeoutMs,
85
+ dataDirectory: dataDirectory(config),
86
+ },
87
+ environments: Object.values(config.environments).map(describeEnvironment),
88
+ // Code-repository access used by the diff-driven code check. The token is
89
+ // reported as presence only: the browser never receives the value.
90
+ gitlab: await describeGitlab(config),
91
+ }
92
+ }
93
+
94
+ /** GitLab settings as the page shows them, including whether a token exists. */
95
+ async function describeGitlab(config) {
96
+ const token = await readGitlabToken(config)
97
+ return {
98
+ url: config.gitlab?.url ?? '',
99
+ defaultProject: config.gitlab?.defaultProject ?? '',
100
+ hasToken: token.ok,
101
+ tokenSource: token.ok ? token.source : undefined,
102
+ tokenMissing: token.ok ? [] : token.missing,
103
+ }
104
+ }
105
+
106
+ /** Bounds for the general numbers, so a typo cannot disable pruning or timeouts. */
107
+ const GENERAL_LIMITS = {
108
+ snapshotKeep: { min: 1, max: 50, label: '快照保留份数' },
109
+ maxRecords: { min: 1, max: 100000, label: '单次返回行数上限' },
110
+ timeoutMs: { min: 5000, max: 300000, label: '请求超时(毫秒)' },
111
+ }
112
+
113
+ /** Copy the validated general numbers from `incoming` into `target`. */
114
+ function applyGeneral(target, incoming) {
115
+ for (const [key, limit] of Object.entries(GENERAL_LIMITS)) {
116
+ if (!(key in (incoming ?? {}))) continue
117
+ const value = Number(incoming[key])
118
+ if (!Number.isInteger(value) || value < limit.min || value > limit.max) {
119
+ throw new D365Error(`${limit.label}必须是 ${limit.min}–${limit.max} 之间的整数`, {
120
+ code: 'invalid-setting',
121
+ hint: `收到的是 ${JSON.stringify(incoming[key])}`,
122
+ })
123
+ }
124
+ target[key] = value
125
+ }
126
+ return target
127
+ }
128
+
129
+ /**
130
+ * Persist tool parameters without touching the environment list.
131
+ *
132
+ * Separate from `saveSettings` on purpose: a page section that only edits
133
+ * "keep N snapshots" must never be able to rewrite (or accidentally drop) the
134
+ * environments it did not load.
135
+ */
136
+ export async function saveGeneral(rawConfig, general) {
137
+ const current = await loadConfig(rawConfig)
138
+ if (!current.configFile) {
139
+ throw new D365Error('插件没有配置 configFile,无法保存', {
140
+ code: 'no-config-file',
141
+ hint: '在 cordis.patch.yml 的 config.configFile 指定 d365.config.json 的绝对路径。',
142
+ })
143
+ }
144
+ const file = await readJson(current.configFile, {})
145
+ applyGeneral(file, general)
146
+ await writeJson(current.configFile, file)
147
+ return readSettings(rawConfig)
148
+ }
149
+
150
+ /**
151
+ * Persist the GitLab block: URL and default project in the config file, the
152
+ * access token in the secrets file.
153
+ *
154
+ * Kept separate from `saveSettings` for the same reason as `saveGeneral`: a
155
+ * form that only edits the repository settings must not rewrite environments.
156
+ */
157
+ export async function saveGitlab(rawConfig, input) {
158
+ const current = await loadConfig(rawConfig)
159
+ if (!current.configFile) {
160
+ throw new D365Error('插件没有配置 configFile,无法保存', {
161
+ code: 'no-config-file',
162
+ hint: '在 cordis.patch.yml 的 config.configFile 指定 d365.config.json 的绝对路径。',
163
+ })
164
+ }
165
+
166
+ const file = await readJson(current.configFile, {})
167
+ const url = String(input?.url ?? '').trim().replace(/\/+$/, '')
168
+ const project = String(input?.defaultProject ?? '').trim().replace(/^\/+|\/+$/g, '')
169
+
170
+ if (url && !/^https:\/\/[^\s/]+/i.test(url)) {
171
+ throw new D365Error('GitLab 地址必须以 https:// 开头', {
172
+ code: 'invalid-gitlab',
173
+ hint: `收到的是 ${JSON.stringify(input?.url ?? '')}`,
174
+ })
175
+ }
176
+ if (project && !/^[\w.-]+(\/[\w.-]+)+$/.test(project)) {
177
+ throw new D365Error('默认项目要写成 group/project 的形式', {
178
+ code: 'invalid-gitlab',
179
+ hint: `收到的是 ${JSON.stringify(project)}`,
180
+ })
181
+ }
182
+
183
+ const gitlab = { ...(file.gitlab ?? {}) }
184
+ // Only fields the caller actually sent are touched: clearing the token must
185
+ // not silently drop the address and project it belongs to.
186
+ if (input?.url !== undefined) {
187
+ if (url) gitlab.url = url
188
+ else delete gitlab.url
189
+ }
190
+ if (input?.defaultProject !== undefined) {
191
+ if (project) gitlab.defaultProject = project
192
+ else delete gitlab.defaultProject
193
+ }
194
+ if (Object.keys(gitlab).length) file.gitlab = gitlab
195
+ else delete file.gitlab
196
+ await writeJson(current.configFile, file)
197
+
198
+ const secretsPath = current.secretsPath ?? join(dirname(current.configFile), SECRETS_FILENAME)
199
+ const secrets = await readJson(secretsPath, {})
200
+ const token = typeof input?.token === 'string' ? input.token.trim() : ''
201
+ if (token) secrets.gitlabToken = token
202
+ else if (input?.clearToken) delete secrets.gitlabToken
203
+
204
+ if (Object.keys(secrets).length) await writeJson(secretsPath, secrets, 0o600)
205
+ else await unlink(secretsPath).catch(() => {})
206
+ if (token) await appendGitignore(dirname(current.configFile))
207
+
208
+ return readSettings(rawConfig)
209
+ }
210
+
211
+ /** Reject a row the UI could have caught, with a message naming the row. */
212
+ function validateRow(key, row) {
213
+ const problems = []
214
+ const url = String(row?.url ?? '').trim().replace(/\/+$/, '')
215
+ if (!url) problems.push('缺少 URL')
216
+ else if (!/^https:\/\/[^\s/]+/i.test(url)) problems.push('URL 必须以 https:// 开头')
217
+ const cloud = String(row?.cloud ?? 'public').trim() || 'public'
218
+ if (!KNOWN_CLOUDS.includes(cloud)) problems.push(`cloud 只能是 ${KNOWN_CLOUDS.join(' / ')}`)
219
+ if (problems.length) {
220
+ throw new D365Error(`环境 "${key}" 配置有误:${problems.join(';')}`, {
221
+ code: 'invalid-environment',
222
+ hint: '例如 https://contoso-dev.crm5.dynamics.com',
223
+ })
224
+ }
225
+ return { url, cloud }
226
+ }
227
+
228
+ /**
229
+ * Persist the environment list.
230
+ *
231
+ * @param rawConfig - the plugin's patch config (supplies `configFile`).
232
+ * @param payload - `{ defaultEnvironment, environments: { key: row } }` where a
233
+ * row may carry `clientSecret` (write it) or `clearSecret` (remove it).
234
+ * @returns the new settings view plus what changed.
235
+ */
236
+ export async function saveSettings(rawConfig, payload) {
237
+ const current = await loadConfig(rawConfig)
238
+ if (!current.configFile) {
239
+ throw new D365Error('插件没有配置 configFile,无法保存', {
240
+ code: 'no-config-file',
241
+ hint: '在 cordis.patch.yml 的 config.configFile 指定 d365.config.json 的绝对路径。',
242
+ })
243
+ }
244
+
245
+ const incoming = payload?.environments && typeof payload.environments === 'object' ? payload.environments : {}
246
+ const existing = await readJson(current.configFile, {})
247
+
248
+ const environments = {}
249
+ for (const [rawKey, rawRow] of Object.entries(incoming)) {
250
+ const key = String(rawKey).trim()
251
+ if (!key) continue
252
+ const { url, cloud } = validateRow(key, rawRow)
253
+ const previous = existing.environments?.[key] ?? {}
254
+ const row = { ...previous, url }
255
+ row.cloud = cloud
256
+ row.label = String(rawRow?.label ?? '').trim() || previous.label || key
257
+ // Empty optional strings are removed rather than stored: a stray "" in the
258
+ // file would win over the environment-variable fallback for that value.
259
+ // A field the request does not mention at all is left untouched — otherwise
260
+ // any client that sends a partial row (the settings page never had boxes for
261
+ // tenantIdEnv / clientIdEnv / note) would silently delete them.
262
+ const mentions = rawRow && typeof rawRow === 'object' ? rawRow : {}
263
+ for (const field of ['tenantId', 'tenantIdEnv', 'clientId', 'clientIdEnv', 'note']) {
264
+ if (!(field in mentions)) continue
265
+ const value = String(mentions[field] ?? '').trim()
266
+ if (value) row[field] = value
267
+ else delete row[field]
268
+ }
269
+ if (cloud === 'public') delete row.cloud
270
+ environments[key] = row
271
+ }
272
+
273
+ const keys = Object.keys(environments)
274
+ const wantedDefault = String(payload?.defaultEnvironment ?? '').trim()
275
+ const defaultEnvironment = keys.includes(wantedDefault)
276
+ ? wantedDefault
277
+ : (keys.includes(existing.defaultEnvironment) ? existing.defaultEnvironment : keys[0])
278
+
279
+ const nextConfig = { ...existing, environments }
280
+ if (defaultEnvironment) nextConfig.defaultEnvironment = defaultEnvironment
281
+ else delete nextConfig.defaultEnvironment
282
+
283
+ await writeJson(current.configFile, nextConfig)
284
+
285
+ // Secrets: only rows that sent one, plus explicit removals.
286
+ const secretsPath = current.secretsPath ?? join(dirname(current.configFile), SECRETS_FILENAME)
287
+ const secrets = await readJson(secretsPath, {})
288
+ const changedSecrets = []
289
+ for (const [rawKey, rawRow] of Object.entries(incoming)) {
290
+ const key = String(rawKey).trim()
291
+ if (!key || !environments[key]) continue
292
+ const secret = typeof rawRow?.clientSecret === 'string' ? rawRow.clientSecret.trim() : ''
293
+ if (secret) {
294
+ secrets[key] = { ...(secrets[key] ?? {}), clientSecret: secret }
295
+ changedSecrets.push(key)
296
+ } else if (rawRow?.clearSecret) {
297
+ if (secrets[key]) {
298
+ delete secrets[key].clientSecret
299
+ if (!Object.keys(secrets[key]).length) delete secrets[key]
300
+ changedSecrets.push(key)
301
+ }
302
+ }
303
+ }
304
+ // A removed environment loses its secret too, so re-adding the key cannot
305
+ // silently inherit the old credential.
306
+ for (const key of Object.keys(secrets)) {
307
+ // `gitlabToken` is not tied to an environment; it must survive an
308
+ // environment removal.
309
+ if (key === 'gitlabToken') continue
310
+ if (!environments[key]) {
311
+ delete secrets[key]
312
+ changedSecrets.push(key)
313
+ }
314
+ }
315
+
316
+ if (Object.keys(secrets).length) await writeJson(secretsPath, secrets, 0o600)
317
+ else await unlink(secretsPath).catch(() => {})
318
+
319
+ if (changedSecrets.length) await appendGitignore(dirname(current.configFile))
320
+
321
+ // Reload through the normal path so the token cache and the credential
322
+ // overlay see the new values immediately.
323
+ const view = await readSettings(rawConfig)
324
+ return { settings: view, saved: true, secretsTouched: [...new Set(changedSecrets)] }
325
+ }
326
+
327
+ /** Keep the secrets file out of Git when the project already has a .gitignore. */
328
+ async function appendGitignore(directory) {
329
+ const path = join(directory, '.gitignore')
330
+ try {
331
+ const text = await readFile(path, 'utf8')
332
+ if (text.split(/\r?\n/).some((line) => line.trim() === SECRETS_FILENAME)) return
333
+ const join1 = text.endsWith('\n') ? '' : '\n'
334
+ await writeFile(path, `${text}${join1}\n# D365 client secrets typed into the settings page\n${SECRETS_FILENAME}\n`, 'utf8')
335
+ } catch {
336
+ // No .gitignore here: nothing to protect, and creating one uninvited would
337
+ // be surprising. The settings page says where the file lives.
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Prove one environment can authenticate and read metadata.
343
+ * @returns `{ ok, organization?, error?, hint?, missing? }` — never throws, so
344
+ * the settings page can show the failure next to the row.
345
+ */
346
+ export async function checkEnvironment(rawConfig, key) {
347
+ const config = await loadConfig(rawConfig)
348
+ const env = config.environments[key]
349
+ if (!env) {
350
+ return { ok: false, error: `没有名为 ${key} 的环境`, missing: [] }
351
+ }
352
+
353
+ const status = credentialStatus(env)
354
+ if (!status.complete) {
355
+ return {
356
+ ok: false,
357
+ error: '凭据不全,无法连接',
358
+ missing: Object.entries(status.credentials)
359
+ .filter(([, value]) => !value.ok)
360
+ .map(([kind, value]) => ({ kind, missing: value.missing })),
361
+ }
362
+ }
363
+
364
+ try {
365
+ const client = new DataverseClient(env, { timeoutMs: config.timeoutMs })
366
+ const organization = await readOrganization(client)
367
+ return { ok: true, organization }
368
+ } catch (error) {
369
+ return { ok: false, error: error.message, hint: error.hint }
370
+ }
371
+ }