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.
- package/CHANGELOG.md +319 -308
- package/CHANGELOG.zh-CN.md +318 -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/settings-store.js
CHANGED
|
@@ -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
|
+
}
|