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
package/lib/config.js CHANGED
@@ -1,323 +1,323 @@
1
- /**
2
- * Configuration loading and credential resolution.
3
- *
4
- * The plugin never stores a secret in the patch: the patch is a plain-text
5
- * file that ends up in a profile directory and, in this project's case, in
6
- * Git. Configuration names environment variables; the values live in the
7
- * process environment.
8
- */
9
- import { readFile } from 'node:fs/promises'
10
- import { existsSync } from 'node:fs'
11
- import { dirname, isAbsolute, join, resolve } from 'node:path'
12
- import { DEFAULT_RETENTION } from './retention-defaults.js'
13
- import { DEFAULT_BUSY_WINDOW_MINUTES } from './preflight.js'
14
-
15
- export const DEFAULT_TIMEOUT_MS = 60_000
16
- export const DEFAULT_MAX_RECORDS = 200
17
-
18
- /** How many snapshots to keep per environment before pruning the oldest. */
19
- export const DEFAULT_SNAPSHOT_KEEP = 2
20
- /** Environment-variable suffix used by the derived `D365_<ENV>_<SUFFIX>` names. */
21
- const CREDENTIAL_SUFFIX = {
22
- tenantId: 'TENANT_ID',
23
- clientId: 'CLIENT_ID',
24
- clientSecret: 'CLIENT_SECRET',
25
- }
26
-
27
- /** File beside the config file that holds secrets typed into the settings UI. */
28
- export const SECRETS_FILENAME = 'd365.secrets.json'
29
-
30
- /**
31
- * Where `d365.config.json` lives when the installed patch does not name it.
32
- *
33
- * A bundle installed by somebody else must not carry this machine's absolute
34
- * path, and the harness process working directory is its profile directory, so
35
- * neither the patch nor `cwd` is a usable default. Candidates, in order:
36
- * `D365_CONFIG_FILE`, the default DSH workspace under the user's Documents,
37
- * then `cwd`. The directory has to exist for the settings page to create the
38
- * file in it; a file that is not there yet is normal on a fresh install.
39
- */
40
- export function defaultConfigFile() {
41
- const fromEnv = typeof process.env.D365_CONFIG_FILE === 'string' ? process.env.D365_CONFIG_FILE.trim() : ''
42
- if (fromEnv) return fromEnv
43
- const home = process.env.USERPROFILE ?? process.env.HOME
44
- if (home) {
45
- const candidate = join(home, 'Documents', 'deepseek-harness', 'default-workspace', 'd365.config.json')
46
- if (existsSync(dirname(candidate))) return candidate
47
- }
48
- return join(process.cwd(), 'd365.config.json')
49
- }
50
-
51
- /**
52
- * Directory local data (snapshots, query artifacts) lives under.
53
- *
54
- * The config file's own directory is the fallback because the harness process
55
- * working directory is its profile directory, not the project: artifacts must
56
- * land next to the file the user edits. An explicit `workspaceDir` wins.
57
- */
58
- export function dataDirectory(config) {
59
- if (config.workspaceDir) return config.workspaceDir
60
- if (config.configFile) return dirname(config.configFile)
61
- return process.cwd()
62
- }
63
-
64
- /**
65
- * Secrets loaded from the local secrets file, keyed `${envKey}:${kind}`.
66
- *
67
- * They live in a module-level map rather than in the config object so that a
68
- * resolved environment can never be serialised with its secret attached: the
69
- * tools report environment shapes, and only `resolveCredential` touches this.
70
- */
71
- const secretOverlay = new Map()
72
-
73
- export function setStoredSecret(envKey, kind, value) {
74
- if (value) secretOverlay.set(`${envKey}:${kind}`, value)
75
- else secretOverlay.delete(`${envKey}:${kind}`)
76
- }
77
-
78
- export function forgetStoredSecrets(envKey) {
79
- for (const key of [...secretOverlay.keys()]) {
80
- if (key.startsWith(`${envKey}:`)) secretOverlay.delete(key)
81
- }
82
- }
83
-
84
- const CREDENTIAL_LABEL = {
85
- tenantId: '租户 ID',
86
- clientId: '应用用户 ClientId',
87
- clientSecret: '客户端密钥',
88
- }
89
-
90
- /**
91
- * Normalize the optional GitLab block.
92
- *
93
- * The repository URL and the default project are not secrets, so they live in
94
- * the config file; the access token is stored beside it in the secrets file.
95
- */
96
- export function normalizeGitlab(raw) {
97
- const source = raw && typeof raw === 'object' ? raw : {}
98
- return {
99
- url: text(source.url)?.replace(/\/+$/, '') ?? null,
100
- defaultProject: text(source.defaultProject)?.replace(/^\/+|\/+$/g, '') ?? null,
101
- }
102
- }
103
-
104
- /**
105
- * Resolve the GitLab access token: environment first, then the secrets file.
106
- *
107
- * Environment variables win so CI or a shared machine can inject a token
108
- * without touching a file.
109
- */
110
- export async function readGitlabToken(config) {
111
- for (const name of ['D365_GITLAB_TOKEN', 'GITLAB_TOKEN']) {
112
- if (process.env[name]) return { ok: true, value: process.env[name], source: `env:${name}` }
113
- }
114
- const path = config.secretsPath
115
- ?? (config.configFile ? join(dirname(config.configFile), SECRETS_FILENAME) : null)
116
- if (!path) return { ok: false, missing: ['D365_GITLAB_TOKEN'] }
117
- const secrets = await readSecrets(path)
118
- const token = typeof secrets.gitlabToken === 'string' ? secrets.gitlabToken.trim() : ''
119
- return token ? { ok: true, value: token, source: 'secrets' } : { ok: false, missing: ['D365_GITLAB_TOKEN'] }
120
- }
121
-
122
- function positiveNumber(value, fallback) {
123
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
124
- }
125
-
126
- /** Same as `positiveNumber`, but zero is meaningful (keep nothing). */
127
- function nonNegativeNumber(value, fallback) {
128
- return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
129
- }
130
-
131
- function text(value) {
132
- return typeof value === 'string' && value.trim() ? value.trim() : undefined
133
- }
134
-
135
- /** A list of non-empty strings, falling back to `fallback` when absent/empty. */
136
- function stringList(value, fallback) {
137
- if (!Array.isArray(value)) return fallback
138
- const items = value.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
139
- return items.length ? items : fallback
140
- }
141
-
142
- /**
143
- * Normalize one raw config object (patch config or config file) into the shape
144
- * the rest of the plugin consumes. Nothing here touches the filesystem.
145
- * @param raw - the raw config value handed to the plugin.
146
- * @returns normalized config plus a list of human-readable problems.
147
- */
148
- export function normalizeConfig(raw) {
149
- const source = raw && typeof raw === 'object' ? raw : {}
150
- const issues = []
151
- const environments = {}
152
-
153
- for (const [key, value] of Object.entries(source.environments ?? {})) {
154
- const entry = value && typeof value === 'object' ? value : {}
155
- const url = text(entry.url)?.replace(/\/+$/, '')
156
- if (!url) {
157
- issues.push(`环境 "${key}" 缺少 url,已忽略`)
158
- continue
159
- }
160
- environments[key] = {
161
- key,
162
- label: text(entry.label) ?? key,
163
- url,
164
- cloud: text(entry.cloud) ?? 'public',
165
- authorityHost: text(entry.authorityHost),
166
- tenantId: text(entry.tenantId),
167
- tenantIdEnv: text(entry.tenantIdEnv),
168
- clientId: text(entry.clientId),
169
- clientIdEnv: text(entry.clientIdEnv),
170
- clientSecret: text(entry.clientSecret),
171
- clientSecretEnv: text(entry.clientSecretEnv),
172
- note: text(entry.note),
173
- }
174
- }
175
-
176
- const keys = Object.keys(environments)
177
- const requestedDefault = text(source.defaultEnvironment)
178
- const defaultEnvironment = requestedDefault && keys.includes(requestedDefault)
179
- ? requestedDefault
180
- : keys[0]
181
-
182
- if (requestedDefault && !keys.includes(requestedDefault) && keys.length) {
183
- issues.push(`defaultEnvironment "${requestedDefault}" 不在 environments 中,已回退到 ${keys[0] ?? '(无)'}`)
184
- }
185
-
186
- return {
187
- environments,
188
- defaultEnvironment,
189
- timeoutMs: positiveNumber(source.timeoutMs, DEFAULT_TIMEOUT_MS),
190
- maxRecords: positiveNumber(source.maxRecords, DEFAULT_MAX_RECORDS),
191
- configFile: text(source.configFile),
192
- workspaceDir: text(source.workspaceDir),
193
- snapshotKeep: positiveNumber(source.snapshotKeep, DEFAULT_SNAPSHOT_KEEP),
194
- busyWindowMinutes: positiveNumber(source.busyWindowMinutes, DEFAULT_BUSY_WINDOW_MINUTES),
195
- snapshotMaxAgeDays: positiveNumber(source.snapshotMaxAgeDays, DEFAULT_RETENTION.snapshotMaxAgeDays),
196
- reportKeep: nonNegativeNumber(source.reportKeep, DEFAULT_RETENTION.reportKeep),
197
- reportMaxAgeDays: positiveNumber(source.reportMaxAgeDays, DEFAULT_RETENTION.reportMaxAgeDays),
198
- diffKeep: nonNegativeNumber(source.diffKeep, DEFAULT_RETENTION.diffKeep),
199
- diffMaxAgeDays: positiveNumber(source.diffMaxAgeDays, DEFAULT_RETENTION.diffMaxAgeDays),
200
- queryKeep: nonNegativeNumber(source.queryKeep, DEFAULT_RETENTION.queryKeep),
201
- queryMaxAgeDays: positiveNumber(source.queryMaxAgeDays, DEFAULT_RETENTION.queryMaxAgeDays),
202
- changeKeep: nonNegativeNumber(source.changeKeep, DEFAULT_RETENTION.changeKeep),
203
- changeMaxAgeDays: positiveNumber(source.changeMaxAgeDays, DEFAULT_RETENTION.changeMaxAgeDays),
204
- warnMegabytes: positiveNumber(source.warnMegabytes, DEFAULT_RETENTION.warnMegabytes),
205
- gitlab: normalizeGitlab(source.gitlab),
206
- // Publisher prefixes whose script libraries belong to this project. The
207
- // handler check only judges these; platform-injected handlers from system
208
- // libraries are skipped (they are not in the resource a form lists).
209
- // Leave it empty to infer the prefixes from the source environment's
210
- // unmanaged solutions, i.e. the publishers the project actually uses.
211
- handlerPrefixes: stringList(source.handlerPrefixes, []),
212
- issues,
213
- }
214
- }
215
-
216
- /**
217
- * Resolve one credential for an environment without throwing, so callers can
218
- * report *why* an environment is unusable.
219
- * @param env - normalized environment.
220
- * @param kind - which credential to resolve.
221
- * @returns the value and where it came from, or the names that were missing.
222
- */
223
- export function resolveCredential(env, kind) {
224
- const inline = env[kind]
225
- if (inline) return { ok: true, value: inline, source: 'config' }
226
-
227
- const stored = secretOverlay.get(`${env.key}:${kind}`)
228
- if (stored) return { ok: true, value: stored, source: 'secrets' }
229
-
230
- const named = env[`${kind}Env`]
231
- if (named) {
232
- const value = process.env[named]
233
- if (value) return { ok: true, value, source: `env:${named}` }
234
- return { ok: false, missing: [named] }
235
- }
236
-
237
- const upper = env.key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')
238
- const derived = `D365_${upper}_${CREDENTIAL_SUFFIX[kind]}`
239
- if (process.env[derived]) return { ok: true, value: process.env[derived], source: `env:${derived}` }
240
-
241
- const shared = `D365_${CREDENTIAL_SUFFIX[kind]}`
242
- if (process.env[shared]) return { ok: true, value: process.env[shared], source: `env:${shared}` }
243
-
244
- return { ok: false, missing: [derived, shared] }
245
- }
246
-
247
- /**
248
- * Human-readable credential status for the `d365_envs` tool.
249
- * @param env - normalized environment.
250
- * @returns per-credential status, plus whether the environment could authenticate.
251
- */
252
- export function credentialStatus(env) {
253
- const status = {}
254
- let complete = true
255
- for (const kind of ['tenantId', 'clientId', 'clientSecret']) {
256
- const resolved = resolveCredential(env, kind)
257
- status[kind] = resolved.ok
258
- ? { ok: true, source: resolved.source }
259
- : { ok: false, missing: resolved.missing }
260
- if (!resolved.ok) complete = false
261
- }
262
- return { complete, credentials: status, label: CREDENTIAL_LABEL }
263
- }
264
-
265
- /**
266
- * Merge the patch config with an optional JSON config file.
267
- *
268
- * The file wins on conflicts: it is the per-machine, per-project file a human
269
- * edits, while the patch ships defaults. A missing or broken file is reported
270
- * as an issue rather than thrown, because the read-only tools should still be
271
- * able to explain what is wrong.
272
- * @param raw - patch config.
273
- * @returns normalized config, possibly enriched from the file.
274
- */
275
- export async function loadConfig(raw) {
276
- const base = normalizeConfig(raw)
277
- const path = base.configFile
278
- ? (isAbsolute(base.configFile) ? base.configFile : resolve(process.cwd(), base.configFile))
279
- : defaultConfigFile()
280
- try {
281
- // Notepad and many Windows editors save UTF-8 with a BOM, which
282
- // JSON.parse rejects; strip it so a hand-edited config still loads.
283
- const text = (await readFile(path, 'utf8')).replace(/^\uFEFF/, '')
284
- const parsed = JSON.parse(text)
285
- // Merge the raw objects, then normalize once: normalizing both sides first
286
- // would replace "absent" with defaults and make the file silently win on
287
- // settings it never mentioned.
288
- const mergedRaw = { ...(raw && typeof raw === 'object' ? raw : {}), ...(parsed && typeof parsed === 'object' ? parsed : {}) }
289
- mergedRaw.environments = { ...(raw?.environments ?? {}), ...(parsed?.environments ?? {}) }
290
- const merged = normalizeConfig(mergedRaw)
291
-
292
- // Secrets are stored beside the config file, never inside it.
293
- const secretsPath = join(dirname(path), SECRETS_FILENAME)
294
- const secrets = await readSecrets(secretsPath)
295
- for (const envKey of Object.keys(merged.environments)) {
296
- forgetStoredSecrets(envKey)
297
- setStoredSecret(envKey, 'clientSecret', secrets[envKey]?.clientSecret)
298
- }
299
-
300
- return { ...merged, configFile: path, secretsPath }
301
- } catch (error) {
302
- return {
303
- ...base,
304
- configFile: path,
305
- issues: [...base.issues, `读取 configFile 失败 (${path}): ${error.message}`],
306
- }
307
- }
308
- }
309
-
310
- /**
311
- * Read the optional secrets file. A missing file is normal (nobody typed a
312
- * secret yet); a broken one is reported through the config issues of the
313
- * caller's next load, never thrown mid-request.
314
- */
315
- async function readSecrets(path) {
316
- try {
317
- const text = (await readFile(path, 'utf8')).replace(/^\uFEFF/, '')
318
- const parsed = JSON.parse(text)
319
- return parsed && typeof parsed === 'object' ? parsed : {}
320
- } catch {
321
- return {}
322
- }
323
- }
1
+ /**
2
+ * Configuration loading and credential resolution.
3
+ *
4
+ * The plugin never stores a secret in the patch: the patch is a plain-text
5
+ * file that ends up in a profile directory and, in this project's case, in
6
+ * Git. Configuration names environment variables; the values live in the
7
+ * process environment.
8
+ */
9
+ import { readFile } from 'node:fs/promises'
10
+ import { existsSync } from 'node:fs'
11
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
12
+ import { DEFAULT_RETENTION } from './retention-defaults.js'
13
+ import { DEFAULT_BUSY_WINDOW_MINUTES } from './preflight.js'
14
+
15
+ export const DEFAULT_TIMEOUT_MS = 60_000
16
+ export const DEFAULT_MAX_RECORDS = 200
17
+
18
+ /** How many snapshots to keep per environment before pruning the oldest. */
19
+ export const DEFAULT_SNAPSHOT_KEEP = 2
20
+ /** Environment-variable suffix used by the derived `D365_<ENV>_<SUFFIX>` names. */
21
+ const CREDENTIAL_SUFFIX = {
22
+ tenantId: 'TENANT_ID',
23
+ clientId: 'CLIENT_ID',
24
+ clientSecret: 'CLIENT_SECRET',
25
+ }
26
+
27
+ /** File beside the config file that holds secrets typed into the settings UI. */
28
+ export const SECRETS_FILENAME = 'd365.secrets.json'
29
+
30
+ /**
31
+ * Where `d365.config.json` lives when the installed patch does not name it.
32
+ *
33
+ * A bundle installed by somebody else must not carry this machine's absolute
34
+ * path, and the harness process working directory is its profile directory, so
35
+ * neither the patch nor `cwd` is a usable default. Candidates, in order:
36
+ * `D365_CONFIG_FILE`, the default DSH workspace under the user's Documents,
37
+ * then `cwd`. The directory has to exist for the settings page to create the
38
+ * file in it; a file that is not there yet is normal on a fresh install.
39
+ */
40
+ export function defaultConfigFile() {
41
+ const fromEnv = typeof process.env.D365_CONFIG_FILE === 'string' ? process.env.D365_CONFIG_FILE.trim() : ''
42
+ if (fromEnv) return fromEnv
43
+ const home = process.env.USERPROFILE ?? process.env.HOME
44
+ if (home) {
45
+ const candidate = join(home, 'Documents', 'deepseek-harness', 'default-workspace', 'd365.config.json')
46
+ if (existsSync(dirname(candidate))) return candidate
47
+ }
48
+ return join(process.cwd(), 'd365.config.json')
49
+ }
50
+
51
+ /**
52
+ * Directory local data (snapshots, query artifacts) lives under.
53
+ *
54
+ * The config file's own directory is the fallback because the harness process
55
+ * working directory is its profile directory, not the project: artifacts must
56
+ * land next to the file the user edits. An explicit `workspaceDir` wins.
57
+ */
58
+ export function dataDirectory(config) {
59
+ if (config.workspaceDir) return config.workspaceDir
60
+ if (config.configFile) return dirname(config.configFile)
61
+ return process.cwd()
62
+ }
63
+
64
+ /**
65
+ * Secrets loaded from the local secrets file, keyed `${envKey}:${kind}`.
66
+ *
67
+ * They live in a module-level map rather than in the config object so that a
68
+ * resolved environment can never be serialised with its secret attached: the
69
+ * tools report environment shapes, and only `resolveCredential` touches this.
70
+ */
71
+ const secretOverlay = new Map()
72
+
73
+ export function setStoredSecret(envKey, kind, value) {
74
+ if (value) secretOverlay.set(`${envKey}:${kind}`, value)
75
+ else secretOverlay.delete(`${envKey}:${kind}`)
76
+ }
77
+
78
+ export function forgetStoredSecrets(envKey) {
79
+ for (const key of [...secretOverlay.keys()]) {
80
+ if (key.startsWith(`${envKey}:`)) secretOverlay.delete(key)
81
+ }
82
+ }
83
+
84
+ const CREDENTIAL_LABEL = {
85
+ tenantId: '租户 ID',
86
+ clientId: '应用用户 ClientId',
87
+ clientSecret: '客户端密钥',
88
+ }
89
+
90
+ /**
91
+ * Normalize the optional GitLab block.
92
+ *
93
+ * The repository URL and the default project are not secrets, so they live in
94
+ * the config file; the access token is stored beside it in the secrets file.
95
+ */
96
+ export function normalizeGitlab(raw) {
97
+ const source = raw && typeof raw === 'object' ? raw : {}
98
+ return {
99
+ url: text(source.url)?.replace(/\/+$/, '') ?? null,
100
+ defaultProject: text(source.defaultProject)?.replace(/^\/+|\/+$/g, '') ?? null,
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Resolve the GitLab access token: environment first, then the secrets file.
106
+ *
107
+ * Environment variables win so CI or a shared machine can inject a token
108
+ * without touching a file.
109
+ */
110
+ export async function readGitlabToken(config) {
111
+ for (const name of ['D365_GITLAB_TOKEN', 'GITLAB_TOKEN']) {
112
+ if (process.env[name]) return { ok: true, value: process.env[name], source: `env:${name}` }
113
+ }
114
+ const path = config.secretsPath
115
+ ?? (config.configFile ? join(dirname(config.configFile), SECRETS_FILENAME) : null)
116
+ if (!path) return { ok: false, missing: ['D365_GITLAB_TOKEN'] }
117
+ const secrets = await readSecrets(path)
118
+ const token = typeof secrets.gitlabToken === 'string' ? secrets.gitlabToken.trim() : ''
119
+ return token ? { ok: true, value: token, source: 'secrets' } : { ok: false, missing: ['D365_GITLAB_TOKEN'] }
120
+ }
121
+
122
+ function positiveNumber(value, fallback) {
123
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
124
+ }
125
+
126
+ /** Same as `positiveNumber`, but zero is meaningful (keep nothing). */
127
+ function nonNegativeNumber(value, fallback) {
128
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
129
+ }
130
+
131
+ function text(value) {
132
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
133
+ }
134
+
135
+ /** A list of non-empty strings, falling back to `fallback` when absent/empty. */
136
+ function stringList(value, fallback) {
137
+ if (!Array.isArray(value)) return fallback
138
+ const items = value.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
139
+ return items.length ? items : fallback
140
+ }
141
+
142
+ /**
143
+ * Normalize one raw config object (patch config or config file) into the shape
144
+ * the rest of the plugin consumes. Nothing here touches the filesystem.
145
+ * @param raw - the raw config value handed to the plugin.
146
+ * @returns normalized config plus a list of human-readable problems.
147
+ */
148
+ export function normalizeConfig(raw) {
149
+ const source = raw && typeof raw === 'object' ? raw : {}
150
+ const issues = []
151
+ const environments = {}
152
+
153
+ for (const [key, value] of Object.entries(source.environments ?? {})) {
154
+ const entry = value && typeof value === 'object' ? value : {}
155
+ const url = text(entry.url)?.replace(/\/+$/, '')
156
+ if (!url) {
157
+ issues.push(`环境 "${key}" 缺少 url,已忽略`)
158
+ continue
159
+ }
160
+ environments[key] = {
161
+ key,
162
+ label: text(entry.label) ?? key,
163
+ url,
164
+ cloud: text(entry.cloud) ?? 'public',
165
+ authorityHost: text(entry.authorityHost),
166
+ tenantId: text(entry.tenantId),
167
+ tenantIdEnv: text(entry.tenantIdEnv),
168
+ clientId: text(entry.clientId),
169
+ clientIdEnv: text(entry.clientIdEnv),
170
+ clientSecret: text(entry.clientSecret),
171
+ clientSecretEnv: text(entry.clientSecretEnv),
172
+ note: text(entry.note),
173
+ }
174
+ }
175
+
176
+ const keys = Object.keys(environments)
177
+ const requestedDefault = text(source.defaultEnvironment)
178
+ const defaultEnvironment = requestedDefault && keys.includes(requestedDefault)
179
+ ? requestedDefault
180
+ : keys[0]
181
+
182
+ if (requestedDefault && !keys.includes(requestedDefault) && keys.length) {
183
+ issues.push(`defaultEnvironment "${requestedDefault}" 不在 environments 中,已回退到 ${keys[0] ?? '(无)'}`)
184
+ }
185
+
186
+ return {
187
+ environments,
188
+ defaultEnvironment,
189
+ timeoutMs: positiveNumber(source.timeoutMs, DEFAULT_TIMEOUT_MS),
190
+ maxRecords: positiveNumber(source.maxRecords, DEFAULT_MAX_RECORDS),
191
+ configFile: text(source.configFile),
192
+ workspaceDir: text(source.workspaceDir),
193
+ snapshotKeep: positiveNumber(source.snapshotKeep, DEFAULT_SNAPSHOT_KEEP),
194
+ busyWindowMinutes: positiveNumber(source.busyWindowMinutes, DEFAULT_BUSY_WINDOW_MINUTES),
195
+ snapshotMaxAgeDays: positiveNumber(source.snapshotMaxAgeDays, DEFAULT_RETENTION.snapshotMaxAgeDays),
196
+ reportKeep: nonNegativeNumber(source.reportKeep, DEFAULT_RETENTION.reportKeep),
197
+ reportMaxAgeDays: positiveNumber(source.reportMaxAgeDays, DEFAULT_RETENTION.reportMaxAgeDays),
198
+ diffKeep: nonNegativeNumber(source.diffKeep, DEFAULT_RETENTION.diffKeep),
199
+ diffMaxAgeDays: positiveNumber(source.diffMaxAgeDays, DEFAULT_RETENTION.diffMaxAgeDays),
200
+ queryKeep: nonNegativeNumber(source.queryKeep, DEFAULT_RETENTION.queryKeep),
201
+ queryMaxAgeDays: positiveNumber(source.queryMaxAgeDays, DEFAULT_RETENTION.queryMaxAgeDays),
202
+ changeKeep: nonNegativeNumber(source.changeKeep, DEFAULT_RETENTION.changeKeep),
203
+ changeMaxAgeDays: positiveNumber(source.changeMaxAgeDays, DEFAULT_RETENTION.changeMaxAgeDays),
204
+ warnMegabytes: positiveNumber(source.warnMegabytes, DEFAULT_RETENTION.warnMegabytes),
205
+ gitlab: normalizeGitlab(source.gitlab),
206
+ // Publisher prefixes whose script libraries belong to this project. The
207
+ // handler check only judges these; platform-injected handlers from system
208
+ // libraries are skipped (they are not in the resource a form lists).
209
+ // Leave it empty to infer the prefixes from the source environment's
210
+ // unmanaged solutions, i.e. the publishers the project actually uses.
211
+ handlerPrefixes: stringList(source.handlerPrefixes, []),
212
+ issues,
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Resolve one credential for an environment without throwing, so callers can
218
+ * report *why* an environment is unusable.
219
+ * @param env - normalized environment.
220
+ * @param kind - which credential to resolve.
221
+ * @returns the value and where it came from, or the names that were missing.
222
+ */
223
+ export function resolveCredential(env, kind) {
224
+ const inline = env[kind]
225
+ if (inline) return { ok: true, value: inline, source: 'config' }
226
+
227
+ const stored = secretOverlay.get(`${env.key}:${kind}`)
228
+ if (stored) return { ok: true, value: stored, source: 'secrets' }
229
+
230
+ const named = env[`${kind}Env`]
231
+ if (named) {
232
+ const value = process.env[named]
233
+ if (value) return { ok: true, value, source: `env:${named}` }
234
+ return { ok: false, missing: [named] }
235
+ }
236
+
237
+ const upper = env.key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')
238
+ const derived = `D365_${upper}_${CREDENTIAL_SUFFIX[kind]}`
239
+ if (process.env[derived]) return { ok: true, value: process.env[derived], source: `env:${derived}` }
240
+
241
+ const shared = `D365_${CREDENTIAL_SUFFIX[kind]}`
242
+ if (process.env[shared]) return { ok: true, value: process.env[shared], source: `env:${shared}` }
243
+
244
+ return { ok: false, missing: [derived, shared] }
245
+ }
246
+
247
+ /**
248
+ * Human-readable credential status for the `d365_envs` tool.
249
+ * @param env - normalized environment.
250
+ * @returns per-credential status, plus whether the environment could authenticate.
251
+ */
252
+ export function credentialStatus(env) {
253
+ const status = {}
254
+ let complete = true
255
+ for (const kind of ['tenantId', 'clientId', 'clientSecret']) {
256
+ const resolved = resolveCredential(env, kind)
257
+ status[kind] = resolved.ok
258
+ ? { ok: true, source: resolved.source }
259
+ : { ok: false, missing: resolved.missing }
260
+ if (!resolved.ok) complete = false
261
+ }
262
+ return { complete, credentials: status, label: CREDENTIAL_LABEL }
263
+ }
264
+
265
+ /**
266
+ * Merge the patch config with an optional JSON config file.
267
+ *
268
+ * The file wins on conflicts: it is the per-machine, per-project file a human
269
+ * edits, while the patch ships defaults. A missing or broken file is reported
270
+ * as an issue rather than thrown, because the read-only tools should still be
271
+ * able to explain what is wrong.
272
+ * @param raw - patch config.
273
+ * @returns normalized config, possibly enriched from the file.
274
+ */
275
+ export async function loadConfig(raw) {
276
+ const base = normalizeConfig(raw)
277
+ const path = base.configFile
278
+ ? (isAbsolute(base.configFile) ? base.configFile : resolve(process.cwd(), base.configFile))
279
+ : defaultConfigFile()
280
+ try {
281
+ // Notepad and many Windows editors save UTF-8 with a BOM, which
282
+ // JSON.parse rejects; strip it so a hand-edited config still loads.
283
+ const text = (await readFile(path, 'utf8')).replace(/^\uFEFF/, '')
284
+ const parsed = JSON.parse(text)
285
+ // Merge the raw objects, then normalize once: normalizing both sides first
286
+ // would replace "absent" with defaults and make the file silently win on
287
+ // settings it never mentioned.
288
+ const mergedRaw = { ...(raw && typeof raw === 'object' ? raw : {}), ...(parsed && typeof parsed === 'object' ? parsed : {}) }
289
+ mergedRaw.environments = { ...(raw?.environments ?? {}), ...(parsed?.environments ?? {}) }
290
+ const merged = normalizeConfig(mergedRaw)
291
+
292
+ // Secrets are stored beside the config file, never inside it.
293
+ const secretsPath = join(dirname(path), SECRETS_FILENAME)
294
+ const secrets = await readSecrets(secretsPath)
295
+ for (const envKey of Object.keys(merged.environments)) {
296
+ forgetStoredSecrets(envKey)
297
+ setStoredSecret(envKey, 'clientSecret', secrets[envKey]?.clientSecret)
298
+ }
299
+
300
+ return { ...merged, configFile: path, secretsPath }
301
+ } catch (error) {
302
+ return {
303
+ ...base,
304
+ configFile: path,
305
+ issues: [...base.issues, `读取 configFile 失败 (${path}): ${error.message}`],
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Read the optional secrets file. A missing file is normal (nobody typed a
312
+ * secret yet); a broken one is reported through the config issues of the
313
+ * caller's next load, never thrown mid-request.
314
+ */
315
+ async function readSecrets(path) {
316
+ try {
317
+ const text = (await readFile(path, 'utf8')).replace(/^\uFEFF/, '')
318
+ const parsed = JSON.parse(text)
319
+ return parsed && typeof parsed === 'object' ? parsed : {}
320
+ } catch {
321
+ return {}
322
+ }
323
+ }