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-http.js
CHANGED
|
@@ -1,512 +1,512 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP surface for the settings page.
|
|
3
|
-
*
|
|
4
|
-
* The page is plain browser JavaScript, so it reaches the Host over the
|
|
5
|
-
* authenticated `/api` Fetch channel that `ctx.connection.fetch` mounts, rather
|
|
6
|
-
* than a raw route on the bare web server: the channel runs the same Host,
|
|
7
|
-
* Origin and browser-credential checks every other `/api` call gets, so the
|
|
8
|
-
* form cannot become an unauthenticated write path.
|
|
9
|
-
*
|
|
10
|
-
* The write routes add one more guard: the page must send an explicit
|
|
11
|
-
* `x-d365-meta-write` header (and, when an `Origin` survives the channel, match
|
|
12
|
-
* this server's origin). Browser pages cannot send a custom header across
|
|
13
|
-
* origins without a CORS preflight the routes never answer, so "a person
|
|
14
|
-
* confirmed this in the settings page" stays true for metadata changes.
|
|
15
|
-
*
|
|
16
|
-
* Every handler funnels into the same modules the tools use, so the UI and the
|
|
17
|
-
* agent cannot disagree about what was saved.
|
|
18
|
-
*/
|
|
19
|
-
import { appendFile } from 'node:fs/promises'
|
|
20
|
-
import { join } from 'node:path'
|
|
21
|
-
import { readSettings, saveGeneral, saveGitlab, saveSettings, checkEnvironment } from './settings-store.js'
|
|
22
|
-
import { dataDirectory, loadConfig } from './config.js'
|
|
23
|
-
import { checkGitlab } from './gitlab.js'
|
|
24
|
-
import { D365Error } from './errors.js'
|
|
25
|
-
import { CAPABILITIES, allows, readPolicy, savePolicy } from './write-policy.js'
|
|
26
|
-
import { deletePlan, listChanges, listPlans, listPublishReports, readPlan, writePublishReport } from './write-store.js'
|
|
27
|
-
import { executePlan, publishNow, publishTargets } from './write-actions.js'
|
|
28
|
-
import { applyCleanup, compactAllSnapshots, formatBytes, planCleanup, storageSummary } from './retention.js'
|
|
29
|
-
|
|
30
|
-
const BASE = '/api/d365-meta'
|
|
31
|
-
|
|
32
|
-
/** Header the settings page sends on every route that can change an environment. */
|
|
33
|
-
const WRITE_HEADER = 'x-d365-meta-write'
|
|
34
|
-
const WRITE_HEADER_VALUE = '1'
|
|
35
|
-
|
|
36
|
-
/** JSON response helper. */
|
|
37
|
-
function json(status, value) {
|
|
38
|
-
return new Response(JSON.stringify(value), {
|
|
39
|
-
status,
|
|
40
|
-
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Map a thrown error to the response the page can show. */
|
|
45
|
-
function failure(error) {
|
|
46
|
-
if (error instanceof D365Error) return json(400, { error: error.message, hint: error.hint })
|
|
47
|
-
return json(500, { error: error?.message ?? String(error) })
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Register the settings and write routes.
|
|
52
|
-
* @param ctx - `{ connection, origin }`; `origin` is the web server's own origin.
|
|
53
|
-
* @param state - holds the raw patch config.
|
|
54
|
-
* @returns disposer.
|
|
55
|
-
*/
|
|
56
|
-
export function registerRoutes(ctx, state) {
|
|
57
|
-
const fetch = ctx.connection.fetch
|
|
58
|
-
const serverOrigin = ctx.origin
|
|
59
|
-
const disposers = []
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Is this `Origin` the page's own origin?
|
|
63
|
-
*
|
|
64
|
-
* Comparing against `webServer.getOrigin()` alone is fragile: that call throws
|
|
65
|
-
* before the listener binds and its spelling (bind host vs request host) can
|
|
66
|
-
* differ from what the browser sends. A browser request carries other
|
|
67
|
-
* trustworthy hints for the same fact — the request URL and the `Host`
|
|
68
|
-
* header — so any of them matching counts as same-origin. A non-browser client
|
|
69
|
-
* sends no `Origin` at all, which is the property the guard actually needs.
|
|
70
|
-
*/
|
|
71
|
-
function isSameOrigin(origin, request) {
|
|
72
|
-
let parsed
|
|
73
|
-
try {
|
|
74
|
-
parsed = new URL(origin)
|
|
75
|
-
} catch {
|
|
76
|
-
return false
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const candidates = []
|
|
80
|
-
if (serverOrigin) candidates.push(serverOrigin)
|
|
81
|
-
if (request.url) candidates.push(request.url)
|
|
82
|
-
const host = request.headers.get('host')
|
|
83
|
-
if (host) candidates.push(`${parsed.protocol}//${host}`)
|
|
84
|
-
|
|
85
|
-
return candidates.some((candidate) => {
|
|
86
|
-
try {
|
|
87
|
-
return new URL(candidate).origin === parsed.origin
|
|
88
|
-
} catch {
|
|
89
|
-
return false
|
|
90
|
-
}
|
|
91
|
-
})
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Record a rejection, so a wrong guard is diagnosable instead of silent. */
|
|
95
|
-
function logRejection(reason, request) {
|
|
96
|
-
try {
|
|
97
|
-
const line = `${JSON.stringify({
|
|
98
|
-
at: new Date().toISOString(),
|
|
99
|
-
reason,
|
|
100
|
-
origin: request.headers.get('origin'),
|
|
101
|
-
host: request.headers.get('host'),
|
|
102
|
-
url: request.url,
|
|
103
|
-
serverOrigin,
|
|
104
|
-
headers: [...request.headers.keys()].sort().join(','),
|
|
105
|
-
})}\n`
|
|
106
|
-
appendFile(join(dataDirectory(state.rawConfig), '.d365', 'write-guard.log'), line).catch(() => {})
|
|
107
|
-
} catch {
|
|
108
|
-
// Diagnostics are best-effort.
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Browser-only guard for anything that changes an environment.
|
|
114
|
-
*
|
|
115
|
-
* Requiring `Origin` alone does not work: the authenticated `/api` channel
|
|
116
|
-
* rewrites the request URL to `http://dsh.internal` and strips `Origin`, so
|
|
117
|
-
* every real browser call arrived without one (see `.d365/write-guard.log`).
|
|
118
|
-
* The page therefore proves itself with an explicit `x-d365-meta-write` header.
|
|
119
|
-
* A cross-site page cannot send that header without a CORS preflight, and these
|
|
120
|
-
* routes never answer a preflight, so the confirmation still cannot be forged
|
|
121
|
-
* from another origin. An `Origin`, when the channel does pass one through,
|
|
122
|
-
* must additionally be same-origin.
|
|
123
|
-
*/
|
|
124
|
-
function requiresBrowserRequest(request) {
|
|
125
|
-
const origin = request.headers.get('origin')
|
|
126
|
-
if (origin && !isSameOrigin(origin, request)) {
|
|
127
|
-
logRejection('origin-mismatch', request)
|
|
128
|
-
return json(403, {
|
|
129
|
-
error: '写入只能从设置页发起',
|
|
130
|
-
hint: '请求来自其他站点;请在设置页确认计划。',
|
|
131
|
-
})
|
|
132
|
-
}
|
|
133
|
-
if (request.headers.get(WRITE_HEADER) === WRITE_HEADER_VALUE) return undefined
|
|
134
|
-
logRejection('missing-write-header', request)
|
|
135
|
-
return json(403, {
|
|
136
|
-
error: '写入只能从设置页发起',
|
|
137
|
-
hint: '设置页会带上专用的确认头;命令行与工具无法伪造这次确认。',
|
|
138
|
-
})
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
disposers.push(fetch.register({
|
|
142
|
-
path: `${BASE}/config`,
|
|
143
|
-
methods: ['GET', 'POST'],
|
|
144
|
-
requestBody: 'buffered',
|
|
145
|
-
async fetch(request) {
|
|
146
|
-
try {
|
|
147
|
-
if (request.method === 'GET') {
|
|
148
|
-
return json(200, { settings: await readSettings(state.rawConfig) })
|
|
149
|
-
}
|
|
150
|
-
// This route writes the environment list and credentials: it needs the
|
|
151
|
-
// same same-origin guard as every other write route here.
|
|
152
|
-
const guard = requiresBrowserRequest(request)
|
|
153
|
-
if (guard) return guard
|
|
154
|
-
const body = await request.json().catch(() => {
|
|
155
|
-
throw new D365Error('请求体不是合法 JSON', { code: 'bad-json' })
|
|
156
|
-
})
|
|
157
|
-
const result = await saveSettings(state.rawConfig, body)
|
|
158
|
-
return json(200, { settings: result.settings, secretsTouched: result.secretsTouched })
|
|
159
|
-
} catch (error) {
|
|
160
|
-
return failure(error)
|
|
161
|
-
}
|
|
162
|
-
},
|
|
163
|
-
}))
|
|
164
|
-
|
|
165
|
-
disposers.push(fetch.register({
|
|
166
|
-
path: `${BASE}/settings`,
|
|
167
|
-
methods: ['POST'],
|
|
168
|
-
requestBody: 'buffered',
|
|
169
|
-
async fetch(request) {
|
|
170
|
-
try {
|
|
171
|
-
const guard = requiresBrowserRequest(request)
|
|
172
|
-
if (guard) return guard
|
|
173
|
-
const body = await request.json().catch(() => ({}))
|
|
174
|
-
// Two independent forms share this route so the page can save one
|
|
175
|
-
// section without resending the other.
|
|
176
|
-
if (body?.gitlab) {
|
|
177
|
-
const settings = await saveGitlab(state.rawConfig, body.gitlab)
|
|
178
|
-
return json(200, { settings })
|
|
179
|
-
}
|
|
180
|
-
const settings = await saveGeneral(state.rawConfig, body?.general ?? body ?? {})
|
|
181
|
-
return json(200, { settings })
|
|
182
|
-
} catch (error) {
|
|
183
|
-
return failure(error)
|
|
184
|
-
}
|
|
185
|
-
},
|
|
186
|
-
}))
|
|
187
|
-
|
|
188
|
-
disposers.push(fetch.register({
|
|
189
|
-
path: `${BASE}/gitlab/check`,
|
|
190
|
-
methods: ['POST'],
|
|
191
|
-
requestBody: 'buffered',
|
|
192
|
-
async fetch(request) {
|
|
193
|
-
try {
|
|
194
|
-
const guard = requiresBrowserRequest(request)
|
|
195
|
-
if (guard) return guard
|
|
196
|
-
const body = await request.json().catch(() => null)
|
|
197
|
-
const config = await loadConfig(state.rawConfig)
|
|
198
|
-
// Let the form test values before they are saved.
|
|
199
|
-
const candidate = body
|
|
200
|
-
? { ...config, gitlab: { ...config.gitlab, ...(body.url ? { url: String(body.url).replace(/\/+$/, '') } : {}) } }
|
|
201
|
-
: config
|
|
202
|
-
return json(200, await checkGitlab(candidate))
|
|
203
|
-
} catch (error) {
|
|
204
|
-
return failure(error)
|
|
205
|
-
}
|
|
206
|
-
},
|
|
207
|
-
}))
|
|
208
|
-
|
|
209
|
-
disposers.push(fetch.register({
|
|
210
|
-
path: `${BASE}/storage`,
|
|
211
|
-
methods: ['GET'],
|
|
212
|
-
// Every exact route must declare a body mode. Without it the channel falls
|
|
213
|
-
// back to streaming and constructs a GET request with a body, which throws
|
|
214
|
-
// and surfaces as a bare HTTP 400 in the page.
|
|
215
|
-
requestBody: 'buffered',
|
|
216
|
-
async fetch() {
|
|
217
|
-
try {
|
|
218
|
-
const config = await loadConfig(state.rawConfig)
|
|
219
|
-
return json(200, { storage: await storageSummary(config) })
|
|
220
|
-
} catch (error) {
|
|
221
|
-
return failure(error)
|
|
222
|
-
}
|
|
223
|
-
},
|
|
224
|
-
}))
|
|
225
|
-
|
|
226
|
-
disposers.push(fetch.register({
|
|
227
|
-
path: `${BASE}/storage/cleanup`,
|
|
228
|
-
methods: ['POST'],
|
|
229
|
-
requestBody: 'buffered',
|
|
230
|
-
async fetch(request) {
|
|
231
|
-
try {
|
|
232
|
-
const guard = requiresBrowserRequest(request)
|
|
233
|
-
if (guard) return guard
|
|
234
|
-
const body = await request.json().catch(() => ({}))
|
|
235
|
-
const config = await loadConfig(state.rawConfig)
|
|
236
|
-
const target = ['snapshots', 'reports', 'diffs', 'query', 'changes', 'all'].includes(body?.target)
|
|
237
|
-
? body.target
|
|
238
|
-
: 'all'
|
|
239
|
-
const plan = await planCleanup(config, { target })
|
|
240
|
-
if (body?.dryRun !== false) {
|
|
241
|
-
return json(200, {
|
|
242
|
-
dryRun: true,
|
|
243
|
-
plan: {
|
|
244
|
-
target,
|
|
245
|
-
totalFiles: plan.totalFiles,
|
|
246
|
-
totalBytes: plan.totalBytes,
|
|
247
|
-
totalText: formatBytes(plan.totalBytes),
|
|
248
|
-
classes: plan.classes.map((row) => ({
|
|
249
|
-
id: row.id,
|
|
250
|
-
label: row.label,
|
|
251
|
-
count: row.count,
|
|
252
|
-
files: row.remove.length,
|
|
253
|
-
bytes: row.bytes,
|
|
254
|
-
bytesText: formatBytes(row.bytes),
|
|
255
|
-
keep: row.keep,
|
|
256
|
-
maxAgeDays: row.maxAgeDays,
|
|
257
|
-
})),
|
|
258
|
-
},
|
|
259
|
-
storage: await storageSummary(config),
|
|
260
|
-
})
|
|
261
|
-
}
|
|
262
|
-
const applied = await applyCleanup(config, plan)
|
|
263
|
-
return json(200, {
|
|
264
|
-
dryRun: false,
|
|
265
|
-
deleted: applied.deleted.length,
|
|
266
|
-
bytes: applied.bytes,
|
|
267
|
-
bytesText: formatBytes(applied.bytes),
|
|
268
|
-
failures: applied.failures,
|
|
269
|
-
storage: await storageSummary(config),
|
|
270
|
-
})
|
|
271
|
-
} catch (error) {
|
|
272
|
-
return failure(error)
|
|
273
|
-
}
|
|
274
|
-
},
|
|
275
|
-
}))
|
|
276
|
-
|
|
277
|
-
disposers.push(fetch.register({
|
|
278
|
-
path: `${BASE}/storage/compact`,
|
|
279
|
-
methods: ['POST'],
|
|
280
|
-
requestBody: 'buffered',
|
|
281
|
-
async fetch(request) {
|
|
282
|
-
try {
|
|
283
|
-
const guard = requiresBrowserRequest(request)
|
|
284
|
-
if (guard) return guard
|
|
285
|
-
const config = await loadConfig(state.rawConfig)
|
|
286
|
-
const result = await compactAllSnapshots(config)
|
|
287
|
-
return json(200, {
|
|
288
|
-
snapshots: result.snapshots,
|
|
289
|
-
files: result.files,
|
|
290
|
-
savedBytes: result.savedBytes,
|
|
291
|
-
savedText: formatBytes(result.savedBytes),
|
|
292
|
-
storage: await storageSummary(config),
|
|
293
|
-
})
|
|
294
|
-
} catch (error) {
|
|
295
|
-
return failure(error)
|
|
296
|
-
}
|
|
297
|
-
},
|
|
298
|
-
}))
|
|
299
|
-
|
|
300
|
-
disposers.push(fetch.register({
|
|
301
|
-
path: `${BASE}/check`,
|
|
302
|
-
methods: ['POST'],
|
|
303
|
-
requestBody: 'buffered',
|
|
304
|
-
async fetch(request) {
|
|
305
|
-
try {
|
|
306
|
-
const body = await request.json().catch(() => ({}))
|
|
307
|
-
const key = String(body?.key ?? '').trim()
|
|
308
|
-
if (!key) return json(400, { error: '缺少环境 key' })
|
|
309
|
-
return json(200, await checkEnvironment(state.rawConfig, key))
|
|
310
|
-
} catch (error) {
|
|
311
|
-
return failure(error)
|
|
312
|
-
}
|
|
313
|
-
},
|
|
314
|
-
}))
|
|
315
|
-
|
|
316
|
-
disposers.push(fetch.register({
|
|
317
|
-
path: `${BASE}/write/policy`,
|
|
318
|
-
methods: ['GET', 'POST'],
|
|
319
|
-
requestBody: 'buffered',
|
|
320
|
-
async fetch(request) {
|
|
321
|
-
try {
|
|
322
|
-
const config = await loadConfig(state.rawConfig)
|
|
323
|
-
if (request.method === 'GET') {
|
|
324
|
-
const policy = await readPolicy(config.configFile)
|
|
325
|
-
return json(200, {
|
|
326
|
-
policy,
|
|
327
|
-
capabilities: CAPABILITIES,
|
|
328
|
-
environments: Object.keys(config.environments),
|
|
329
|
-
})
|
|
330
|
-
}
|
|
331
|
-
const guard = requiresBrowserRequest(request)
|
|
332
|
-
if (guard) return guard
|
|
333
|
-
const body = await request.json().catch(() => ({}))
|
|
334
|
-
const policy = await savePolicy(config.configFile, body ?? {})
|
|
335
|
-
return json(200, { policy, capabilities: CAPABILITIES, environments: Object.keys(config.environments) })
|
|
336
|
-
} catch (error) {
|
|
337
|
-
return failure(error)
|
|
338
|
-
}
|
|
339
|
-
},
|
|
340
|
-
}))
|
|
341
|
-
|
|
342
|
-
disposers.push(fetch.register({
|
|
343
|
-
path: `${BASE}/write/plans`,
|
|
344
|
-
methods: ['GET'],
|
|
345
|
-
requestBody: 'buffered',
|
|
346
|
-
async fetch() {
|
|
347
|
-
try {
|
|
348
|
-
const config = await loadConfig(state.rawConfig)
|
|
349
|
-
return json(200, {
|
|
350
|
-
plans: await listPlans(config),
|
|
351
|
-
changes: await listChanges(config, 10),
|
|
352
|
-
publishes: await listPublishReports(config),
|
|
353
|
-
})
|
|
354
|
-
} catch (error) {
|
|
355
|
-
return failure(error)
|
|
356
|
-
}
|
|
357
|
-
},
|
|
358
|
-
}))
|
|
359
|
-
|
|
360
|
-
disposers.push(fetch.register({
|
|
361
|
-
path: `${BASE}/write/execute`,
|
|
362
|
-
methods: ['POST'],
|
|
363
|
-
requestBody: 'buffered',
|
|
364
|
-
async fetch(request) {
|
|
365
|
-
try {
|
|
366
|
-
const guard = requiresBrowserRequest(request)
|
|
367
|
-
if (guard) return guard
|
|
368
|
-
const body = await request.json().catch(() => ({}))
|
|
369
|
-
const planId = String(body?.planId ?? '').trim()
|
|
370
|
-
if (!planId) return json(400, { error: '缺少 planId' })
|
|
371
|
-
|
|
372
|
-
const config = await loadConfig(state.rawConfig)
|
|
373
|
-
const plans = await listPlans(config)
|
|
374
|
-
const plan = plans.find((item) => item.id === planId)
|
|
375
|
-
if (!plan) {
|
|
376
|
-
// Executing deletes the plan, so "not found" usually means "already
|
|
377
|
-
// done, look at the page that ran it". The ledger is the only evidence
|
|
378
|
-
// left; consult it before claiming the plan was cancelled.
|
|
379
|
-
const done = (await listChanges(config, 50)).find((entry) => entry.planId === planId)
|
|
380
|
-
if (done) {
|
|
381
|
-
const ok = done.applied?.length ?? 0
|
|
382
|
-
const failed = done.failed?.length ?? 0
|
|
383
|
-
return json(409, {
|
|
384
|
-
error: `该计划已经执行过:成功 ${ok} 个,失败 ${failed} 个`,
|
|
385
|
-
executed: true,
|
|
386
|
-
ok,
|
|
387
|
-
failed,
|
|
388
|
-
publishState: done.publishState,
|
|
389
|
-
})
|
|
390
|
-
}
|
|
391
|
-
return json(404, { error: '计划不存在或已被取消' })
|
|
392
|
-
}
|
|
393
|
-
if (plan.status !== 'pending') {
|
|
394
|
-
const label = plan.status === 'running'
|
|
395
|
-
? '正在执行,或上次执行中途中断'
|
|
396
|
-
: plan.status === 'failed' ? '上次执行失败' : '已经执行过'
|
|
397
|
-
return json(409, {
|
|
398
|
-
error: `计划${label}`,
|
|
399
|
-
hint: '先核对 .d365/changes 里的执行记录;要再做一次,请取消该计划并重新生成。',
|
|
400
|
-
status: plan.status,
|
|
401
|
-
})
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// The page is a second front end, not a second policy: the capability is
|
|
405
|
-
// re-checked here so revoking it in the meantime blocks the execution.
|
|
406
|
-
const policy = await readPolicy(config.configFile)
|
|
407
|
-
if (plan.capability && !allows(policy, plan.envKey, plan.capability)) {
|
|
408
|
-
const label = CAPABILITIES[plan.capability]?.label ?? plan.capability
|
|
409
|
-
return json(403, {
|
|
410
|
-
error: `环境 ${plan.envKey} 未授权「${label}」,不能执行这个计划`,
|
|
411
|
-
hint: '在设置页「权限」里勾选该能力后再试。',
|
|
412
|
-
})
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const result = await executePlan(config, plan, undefined, { force: body?.force === true })
|
|
416
|
-
await deletePlan(config, planId)
|
|
417
|
-
return json(200, result)
|
|
418
|
-
} catch (error) {
|
|
419
|
-
return failure(error)
|
|
420
|
-
}
|
|
421
|
-
},
|
|
422
|
-
}))
|
|
423
|
-
|
|
424
|
-
disposers.push(fetch.register({
|
|
425
|
-
path: `${BASE}/write/publish`,
|
|
426
|
-
methods: ['POST'],
|
|
427
|
-
requestBody: 'buffered',
|
|
428
|
-
async fetch(request) {
|
|
429
|
-
try {
|
|
430
|
-
const guard = requiresBrowserRequest(request)
|
|
431
|
-
if (guard) return guard
|
|
432
|
-
const body = await request.json().catch(() => ({}))
|
|
433
|
-
const planId = String(body?.planId ?? '').trim()
|
|
434
|
-
if (!planId) return json(400, { error: '缺少 planId' })
|
|
435
|
-
|
|
436
|
-
const config = await loadConfig(state.rawConfig)
|
|
437
|
-
const entry = (await listChanges(config, 100)).find((item) => item.planId === planId)
|
|
438
|
-
if (!entry) return json(404, { error: '没有找到这个计划的执行记录' })
|
|
439
|
-
if (!(entry.applied?.length > 0)) return json(400, { error: '这个计划没有成功的写入,不需要发布' })
|
|
440
|
-
|
|
441
|
-
// Publishing the result of a write is still part of that write: if the
|
|
442
|
-
// permission was revoked after the plan ran, the person must not be able
|
|
443
|
-
// to push the change live from the page.
|
|
444
|
-
const policyPath = config.configFile
|
|
445
|
-
const policy = policyPath ? await readPolicy(policyPath) : { environments: {} }
|
|
446
|
-
if (entry.capability && !allows(policy, entry.envKey, entry.capability)) {
|
|
447
|
-
return json(403, {
|
|
448
|
-
error: `环境 ${entry.envKey} 未授权 ${entry.capability},不能发布这次改动`,
|
|
449
|
-
hint: '先到「权限」里重新授权,或在环境里手工发布。',
|
|
450
|
-
})
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
const scope = publishTargets(entry.applied)
|
|
454
|
-
const startedAt = new Date().toISOString()
|
|
455
|
-
const outcome = await publishNow(config, entry.envKey, scope, { force: body?.force === true })
|
|
456
|
-
const publish = {
|
|
457
|
-
planId,
|
|
458
|
-
envKey: entry.envKey,
|
|
459
|
-
startedAt,
|
|
460
|
-
finishedAt: new Date().toISOString(),
|
|
461
|
-
targets: scope,
|
|
462
|
-
republished: true,
|
|
463
|
-
ok: outcome.ok,
|
|
464
|
-
mode: outcome.mode,
|
|
465
|
-
...(outcome.error ? { error: outcome.error } : {}),
|
|
466
|
-
}
|
|
467
|
-
await writePublishReport(config, planId, publish)
|
|
468
|
-
return json(200, { publish })
|
|
469
|
-
} catch (error) {
|
|
470
|
-
return failure(error)
|
|
471
|
-
}
|
|
472
|
-
},
|
|
473
|
-
}))
|
|
474
|
-
|
|
475
|
-
disposers.push(fetch.register({
|
|
476
|
-
path: `${BASE}/write/cancel`,
|
|
477
|
-
methods: ['POST'],
|
|
478
|
-
requestBody: 'buffered',
|
|
479
|
-
async fetch(request) {
|
|
480
|
-
try {
|
|
481
|
-
const guard = requiresBrowserRequest(request)
|
|
482
|
-
if (guard) return guard
|
|
483
|
-
const body = await request.json().catch(() => ({}))
|
|
484
|
-
const planId = String(body?.planId ?? '').trim()
|
|
485
|
-
if (!planId) return json(400, { error: '缺少 planId' })
|
|
486
|
-
const config = await loadConfig(state.rawConfig)
|
|
487
|
-
const plan = await readPlan(config, planId)
|
|
488
|
-
if (!plan) return json(404, { error: '计划不存在或已被取消' })
|
|
489
|
-
// Cancelling a running plan would hide writes that are still landing: the
|
|
490
|
-
// execution loop has already read the operations it is applying.
|
|
491
|
-
if (plan.status && plan.status !== 'pending') {
|
|
492
|
-
const label = plan.status === 'running'
|
|
493
|
-
? '正在执行,或上次执行中途中断'
|
|
494
|
-
: plan.status === 'failed' ? '上次执行失败' : '已经执行过'
|
|
495
|
-
return json(409, {
|
|
496
|
-
error: `计划${label},不能取消`,
|
|
497
|
-
hint: '先到 .d365/changes 核对执行记录。',
|
|
498
|
-
status: plan.status,
|
|
499
|
-
})
|
|
500
|
-
}
|
|
501
|
-
const removed = await deletePlan(config, planId)
|
|
502
|
-
return json(200, { removed })
|
|
503
|
-
} catch (error) {
|
|
504
|
-
return failure(error)
|
|
505
|
-
}
|
|
506
|
-
},
|
|
507
|
-
}))
|
|
508
|
-
|
|
509
|
-
return () => {
|
|
510
|
-
for (const dispose of disposers) Promise.resolve(dispose()).catch(() => {})
|
|
511
|
-
}
|
|
512
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* HTTP surface for the settings page.
|
|
3
|
+
*
|
|
4
|
+
* The page is plain browser JavaScript, so it reaches the Host over the
|
|
5
|
+
* authenticated `/api` Fetch channel that `ctx.connection.fetch` mounts, rather
|
|
6
|
+
* than a raw route on the bare web server: the channel runs the same Host,
|
|
7
|
+
* Origin and browser-credential checks every other `/api` call gets, so the
|
|
8
|
+
* form cannot become an unauthenticated write path.
|
|
9
|
+
*
|
|
10
|
+
* The write routes add one more guard: the page must send an explicit
|
|
11
|
+
* `x-d365-meta-write` header (and, when an `Origin` survives the channel, match
|
|
12
|
+
* this server's origin). Browser pages cannot send a custom header across
|
|
13
|
+
* origins without a CORS preflight the routes never answer, so "a person
|
|
14
|
+
* confirmed this in the settings page" stays true for metadata changes.
|
|
15
|
+
*
|
|
16
|
+
* Every handler funnels into the same modules the tools use, so the UI and the
|
|
17
|
+
* agent cannot disagree about what was saved.
|
|
18
|
+
*/
|
|
19
|
+
import { appendFile } from 'node:fs/promises'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { readSettings, saveGeneral, saveGitlab, saveSettings, checkEnvironment } from './settings-store.js'
|
|
22
|
+
import { dataDirectory, loadConfig } from './config.js'
|
|
23
|
+
import { checkGitlab } from './gitlab.js'
|
|
24
|
+
import { D365Error } from './errors.js'
|
|
25
|
+
import { CAPABILITIES, allows, readPolicy, savePolicy } from './write-policy.js'
|
|
26
|
+
import { deletePlan, listChanges, listPlans, listPublishReports, readPlan, writePublishReport } from './write-store.js'
|
|
27
|
+
import { executePlan, publishNow, publishTargets } from './write-actions.js'
|
|
28
|
+
import { applyCleanup, compactAllSnapshots, formatBytes, planCleanup, storageSummary } from './retention.js'
|
|
29
|
+
|
|
30
|
+
const BASE = '/api/d365-meta'
|
|
31
|
+
|
|
32
|
+
/** Header the settings page sends on every route that can change an environment. */
|
|
33
|
+
const WRITE_HEADER = 'x-d365-meta-write'
|
|
34
|
+
const WRITE_HEADER_VALUE = '1'
|
|
35
|
+
|
|
36
|
+
/** JSON response helper. */
|
|
37
|
+
function json(status, value) {
|
|
38
|
+
return new Response(JSON.stringify(value), {
|
|
39
|
+
status,
|
|
40
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Map a thrown error to the response the page can show. */
|
|
45
|
+
function failure(error) {
|
|
46
|
+
if (error instanceof D365Error) return json(400, { error: error.message, hint: error.hint })
|
|
47
|
+
return json(500, { error: error?.message ?? String(error) })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register the settings and write routes.
|
|
52
|
+
* @param ctx - `{ connection, origin }`; `origin` is the web server's own origin.
|
|
53
|
+
* @param state - holds the raw patch config.
|
|
54
|
+
* @returns disposer.
|
|
55
|
+
*/
|
|
56
|
+
export function registerRoutes(ctx, state) {
|
|
57
|
+
const fetch = ctx.connection.fetch
|
|
58
|
+
const serverOrigin = ctx.origin
|
|
59
|
+
const disposers = []
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Is this `Origin` the page's own origin?
|
|
63
|
+
*
|
|
64
|
+
* Comparing against `webServer.getOrigin()` alone is fragile: that call throws
|
|
65
|
+
* before the listener binds and its spelling (bind host vs request host) can
|
|
66
|
+
* differ from what the browser sends. A browser request carries other
|
|
67
|
+
* trustworthy hints for the same fact — the request URL and the `Host`
|
|
68
|
+
* header — so any of them matching counts as same-origin. A non-browser client
|
|
69
|
+
* sends no `Origin` at all, which is the property the guard actually needs.
|
|
70
|
+
*/
|
|
71
|
+
function isSameOrigin(origin, request) {
|
|
72
|
+
let parsed
|
|
73
|
+
try {
|
|
74
|
+
parsed = new URL(origin)
|
|
75
|
+
} catch {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const candidates = []
|
|
80
|
+
if (serverOrigin) candidates.push(serverOrigin)
|
|
81
|
+
if (request.url) candidates.push(request.url)
|
|
82
|
+
const host = request.headers.get('host')
|
|
83
|
+
if (host) candidates.push(`${parsed.protocol}//${host}`)
|
|
84
|
+
|
|
85
|
+
return candidates.some((candidate) => {
|
|
86
|
+
try {
|
|
87
|
+
return new URL(candidate).origin === parsed.origin
|
|
88
|
+
} catch {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Record a rejection, so a wrong guard is diagnosable instead of silent. */
|
|
95
|
+
function logRejection(reason, request) {
|
|
96
|
+
try {
|
|
97
|
+
const line = `${JSON.stringify({
|
|
98
|
+
at: new Date().toISOString(),
|
|
99
|
+
reason,
|
|
100
|
+
origin: request.headers.get('origin'),
|
|
101
|
+
host: request.headers.get('host'),
|
|
102
|
+
url: request.url,
|
|
103
|
+
serverOrigin,
|
|
104
|
+
headers: [...request.headers.keys()].sort().join(','),
|
|
105
|
+
})}\n`
|
|
106
|
+
appendFile(join(dataDirectory(state.rawConfig), '.d365', 'write-guard.log'), line).catch(() => {})
|
|
107
|
+
} catch {
|
|
108
|
+
// Diagnostics are best-effort.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Browser-only guard for anything that changes an environment.
|
|
114
|
+
*
|
|
115
|
+
* Requiring `Origin` alone does not work: the authenticated `/api` channel
|
|
116
|
+
* rewrites the request URL to `http://dsh.internal` and strips `Origin`, so
|
|
117
|
+
* every real browser call arrived without one (see `.d365/write-guard.log`).
|
|
118
|
+
* The page therefore proves itself with an explicit `x-d365-meta-write` header.
|
|
119
|
+
* A cross-site page cannot send that header without a CORS preflight, and these
|
|
120
|
+
* routes never answer a preflight, so the confirmation still cannot be forged
|
|
121
|
+
* from another origin. An `Origin`, when the channel does pass one through,
|
|
122
|
+
* must additionally be same-origin.
|
|
123
|
+
*/
|
|
124
|
+
function requiresBrowserRequest(request) {
|
|
125
|
+
const origin = request.headers.get('origin')
|
|
126
|
+
if (origin && !isSameOrigin(origin, request)) {
|
|
127
|
+
logRejection('origin-mismatch', request)
|
|
128
|
+
return json(403, {
|
|
129
|
+
error: '写入只能从设置页发起',
|
|
130
|
+
hint: '请求来自其他站点;请在设置页确认计划。',
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
if (request.headers.get(WRITE_HEADER) === WRITE_HEADER_VALUE) return undefined
|
|
134
|
+
logRejection('missing-write-header', request)
|
|
135
|
+
return json(403, {
|
|
136
|
+
error: '写入只能从设置页发起',
|
|
137
|
+
hint: '设置页会带上专用的确认头;命令行与工具无法伪造这次确认。',
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
disposers.push(fetch.register({
|
|
142
|
+
path: `${BASE}/config`,
|
|
143
|
+
methods: ['GET', 'POST'],
|
|
144
|
+
requestBody: 'buffered',
|
|
145
|
+
async fetch(request) {
|
|
146
|
+
try {
|
|
147
|
+
if (request.method === 'GET') {
|
|
148
|
+
return json(200, { settings: await readSettings(state.rawConfig) })
|
|
149
|
+
}
|
|
150
|
+
// This route writes the environment list and credentials: it needs the
|
|
151
|
+
// same same-origin guard as every other write route here.
|
|
152
|
+
const guard = requiresBrowserRequest(request)
|
|
153
|
+
if (guard) return guard
|
|
154
|
+
const body = await request.json().catch(() => {
|
|
155
|
+
throw new D365Error('请求体不是合法 JSON', { code: 'bad-json' })
|
|
156
|
+
})
|
|
157
|
+
const result = await saveSettings(state.rawConfig, body)
|
|
158
|
+
return json(200, { settings: result.settings, secretsTouched: result.secretsTouched })
|
|
159
|
+
} catch (error) {
|
|
160
|
+
return failure(error)
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
}))
|
|
164
|
+
|
|
165
|
+
disposers.push(fetch.register({
|
|
166
|
+
path: `${BASE}/settings`,
|
|
167
|
+
methods: ['POST'],
|
|
168
|
+
requestBody: 'buffered',
|
|
169
|
+
async fetch(request) {
|
|
170
|
+
try {
|
|
171
|
+
const guard = requiresBrowserRequest(request)
|
|
172
|
+
if (guard) return guard
|
|
173
|
+
const body = await request.json().catch(() => ({}))
|
|
174
|
+
// Two independent forms share this route so the page can save one
|
|
175
|
+
// section without resending the other.
|
|
176
|
+
if (body?.gitlab) {
|
|
177
|
+
const settings = await saveGitlab(state.rawConfig, body.gitlab)
|
|
178
|
+
return json(200, { settings })
|
|
179
|
+
}
|
|
180
|
+
const settings = await saveGeneral(state.rawConfig, body?.general ?? body ?? {})
|
|
181
|
+
return json(200, { settings })
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return failure(error)
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
}))
|
|
187
|
+
|
|
188
|
+
disposers.push(fetch.register({
|
|
189
|
+
path: `${BASE}/gitlab/check`,
|
|
190
|
+
methods: ['POST'],
|
|
191
|
+
requestBody: 'buffered',
|
|
192
|
+
async fetch(request) {
|
|
193
|
+
try {
|
|
194
|
+
const guard = requiresBrowserRequest(request)
|
|
195
|
+
if (guard) return guard
|
|
196
|
+
const body = await request.json().catch(() => null)
|
|
197
|
+
const config = await loadConfig(state.rawConfig)
|
|
198
|
+
// Let the form test values before they are saved.
|
|
199
|
+
const candidate = body
|
|
200
|
+
? { ...config, gitlab: { ...config.gitlab, ...(body.url ? { url: String(body.url).replace(/\/+$/, '') } : {}) } }
|
|
201
|
+
: config
|
|
202
|
+
return json(200, await checkGitlab(candidate))
|
|
203
|
+
} catch (error) {
|
|
204
|
+
return failure(error)
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
}))
|
|
208
|
+
|
|
209
|
+
disposers.push(fetch.register({
|
|
210
|
+
path: `${BASE}/storage`,
|
|
211
|
+
methods: ['GET'],
|
|
212
|
+
// Every exact route must declare a body mode. Without it the channel falls
|
|
213
|
+
// back to streaming and constructs a GET request with a body, which throws
|
|
214
|
+
// and surfaces as a bare HTTP 400 in the page.
|
|
215
|
+
requestBody: 'buffered',
|
|
216
|
+
async fetch() {
|
|
217
|
+
try {
|
|
218
|
+
const config = await loadConfig(state.rawConfig)
|
|
219
|
+
return json(200, { storage: await storageSummary(config) })
|
|
220
|
+
} catch (error) {
|
|
221
|
+
return failure(error)
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
}))
|
|
225
|
+
|
|
226
|
+
disposers.push(fetch.register({
|
|
227
|
+
path: `${BASE}/storage/cleanup`,
|
|
228
|
+
methods: ['POST'],
|
|
229
|
+
requestBody: 'buffered',
|
|
230
|
+
async fetch(request) {
|
|
231
|
+
try {
|
|
232
|
+
const guard = requiresBrowserRequest(request)
|
|
233
|
+
if (guard) return guard
|
|
234
|
+
const body = await request.json().catch(() => ({}))
|
|
235
|
+
const config = await loadConfig(state.rawConfig)
|
|
236
|
+
const target = ['snapshots', 'reports', 'diffs', 'query', 'changes', 'all'].includes(body?.target)
|
|
237
|
+
? body.target
|
|
238
|
+
: 'all'
|
|
239
|
+
const plan = await planCleanup(config, { target })
|
|
240
|
+
if (body?.dryRun !== false) {
|
|
241
|
+
return json(200, {
|
|
242
|
+
dryRun: true,
|
|
243
|
+
plan: {
|
|
244
|
+
target,
|
|
245
|
+
totalFiles: plan.totalFiles,
|
|
246
|
+
totalBytes: plan.totalBytes,
|
|
247
|
+
totalText: formatBytes(plan.totalBytes),
|
|
248
|
+
classes: plan.classes.map((row) => ({
|
|
249
|
+
id: row.id,
|
|
250
|
+
label: row.label,
|
|
251
|
+
count: row.count,
|
|
252
|
+
files: row.remove.length,
|
|
253
|
+
bytes: row.bytes,
|
|
254
|
+
bytesText: formatBytes(row.bytes),
|
|
255
|
+
keep: row.keep,
|
|
256
|
+
maxAgeDays: row.maxAgeDays,
|
|
257
|
+
})),
|
|
258
|
+
},
|
|
259
|
+
storage: await storageSummary(config),
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
const applied = await applyCleanup(config, plan)
|
|
263
|
+
return json(200, {
|
|
264
|
+
dryRun: false,
|
|
265
|
+
deleted: applied.deleted.length,
|
|
266
|
+
bytes: applied.bytes,
|
|
267
|
+
bytesText: formatBytes(applied.bytes),
|
|
268
|
+
failures: applied.failures,
|
|
269
|
+
storage: await storageSummary(config),
|
|
270
|
+
})
|
|
271
|
+
} catch (error) {
|
|
272
|
+
return failure(error)
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
}))
|
|
276
|
+
|
|
277
|
+
disposers.push(fetch.register({
|
|
278
|
+
path: `${BASE}/storage/compact`,
|
|
279
|
+
methods: ['POST'],
|
|
280
|
+
requestBody: 'buffered',
|
|
281
|
+
async fetch(request) {
|
|
282
|
+
try {
|
|
283
|
+
const guard = requiresBrowserRequest(request)
|
|
284
|
+
if (guard) return guard
|
|
285
|
+
const config = await loadConfig(state.rawConfig)
|
|
286
|
+
const result = await compactAllSnapshots(config)
|
|
287
|
+
return json(200, {
|
|
288
|
+
snapshots: result.snapshots,
|
|
289
|
+
files: result.files,
|
|
290
|
+
savedBytes: result.savedBytes,
|
|
291
|
+
savedText: formatBytes(result.savedBytes),
|
|
292
|
+
storage: await storageSummary(config),
|
|
293
|
+
})
|
|
294
|
+
} catch (error) {
|
|
295
|
+
return failure(error)
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
}))
|
|
299
|
+
|
|
300
|
+
disposers.push(fetch.register({
|
|
301
|
+
path: `${BASE}/check`,
|
|
302
|
+
methods: ['POST'],
|
|
303
|
+
requestBody: 'buffered',
|
|
304
|
+
async fetch(request) {
|
|
305
|
+
try {
|
|
306
|
+
const body = await request.json().catch(() => ({}))
|
|
307
|
+
const key = String(body?.key ?? '').trim()
|
|
308
|
+
if (!key) return json(400, { error: '缺少环境 key' })
|
|
309
|
+
return json(200, await checkEnvironment(state.rawConfig, key))
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return failure(error)
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
}))
|
|
315
|
+
|
|
316
|
+
disposers.push(fetch.register({
|
|
317
|
+
path: `${BASE}/write/policy`,
|
|
318
|
+
methods: ['GET', 'POST'],
|
|
319
|
+
requestBody: 'buffered',
|
|
320
|
+
async fetch(request) {
|
|
321
|
+
try {
|
|
322
|
+
const config = await loadConfig(state.rawConfig)
|
|
323
|
+
if (request.method === 'GET') {
|
|
324
|
+
const policy = await readPolicy(config.configFile)
|
|
325
|
+
return json(200, {
|
|
326
|
+
policy,
|
|
327
|
+
capabilities: CAPABILITIES,
|
|
328
|
+
environments: Object.keys(config.environments),
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
const guard = requiresBrowserRequest(request)
|
|
332
|
+
if (guard) return guard
|
|
333
|
+
const body = await request.json().catch(() => ({}))
|
|
334
|
+
const policy = await savePolicy(config.configFile, body ?? {})
|
|
335
|
+
return json(200, { policy, capabilities: CAPABILITIES, environments: Object.keys(config.environments) })
|
|
336
|
+
} catch (error) {
|
|
337
|
+
return failure(error)
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
}))
|
|
341
|
+
|
|
342
|
+
disposers.push(fetch.register({
|
|
343
|
+
path: `${BASE}/write/plans`,
|
|
344
|
+
methods: ['GET'],
|
|
345
|
+
requestBody: 'buffered',
|
|
346
|
+
async fetch() {
|
|
347
|
+
try {
|
|
348
|
+
const config = await loadConfig(state.rawConfig)
|
|
349
|
+
return json(200, {
|
|
350
|
+
plans: await listPlans(config),
|
|
351
|
+
changes: await listChanges(config, 10),
|
|
352
|
+
publishes: await listPublishReports(config),
|
|
353
|
+
})
|
|
354
|
+
} catch (error) {
|
|
355
|
+
return failure(error)
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
}))
|
|
359
|
+
|
|
360
|
+
disposers.push(fetch.register({
|
|
361
|
+
path: `${BASE}/write/execute`,
|
|
362
|
+
methods: ['POST'],
|
|
363
|
+
requestBody: 'buffered',
|
|
364
|
+
async fetch(request) {
|
|
365
|
+
try {
|
|
366
|
+
const guard = requiresBrowserRequest(request)
|
|
367
|
+
if (guard) return guard
|
|
368
|
+
const body = await request.json().catch(() => ({}))
|
|
369
|
+
const planId = String(body?.planId ?? '').trim()
|
|
370
|
+
if (!planId) return json(400, { error: '缺少 planId' })
|
|
371
|
+
|
|
372
|
+
const config = await loadConfig(state.rawConfig)
|
|
373
|
+
const plans = await listPlans(config)
|
|
374
|
+
const plan = plans.find((item) => item.id === planId)
|
|
375
|
+
if (!plan) {
|
|
376
|
+
// Executing deletes the plan, so "not found" usually means "already
|
|
377
|
+
// done, look at the page that ran it". The ledger is the only evidence
|
|
378
|
+
// left; consult it before claiming the plan was cancelled.
|
|
379
|
+
const done = (await listChanges(config, 50)).find((entry) => entry.planId === planId)
|
|
380
|
+
if (done) {
|
|
381
|
+
const ok = done.applied?.length ?? 0
|
|
382
|
+
const failed = done.failed?.length ?? 0
|
|
383
|
+
return json(409, {
|
|
384
|
+
error: `该计划已经执行过:成功 ${ok} 个,失败 ${failed} 个`,
|
|
385
|
+
executed: true,
|
|
386
|
+
ok,
|
|
387
|
+
failed,
|
|
388
|
+
publishState: done.publishState,
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
return json(404, { error: '计划不存在或已被取消' })
|
|
392
|
+
}
|
|
393
|
+
if (plan.status !== 'pending') {
|
|
394
|
+
const label = plan.status === 'running'
|
|
395
|
+
? '正在执行,或上次执行中途中断'
|
|
396
|
+
: plan.status === 'failed' ? '上次执行失败' : '已经执行过'
|
|
397
|
+
return json(409, {
|
|
398
|
+
error: `计划${label}`,
|
|
399
|
+
hint: '先核对 .d365/changes 里的执行记录;要再做一次,请取消该计划并重新生成。',
|
|
400
|
+
status: plan.status,
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// The page is a second front end, not a second policy: the capability is
|
|
405
|
+
// re-checked here so revoking it in the meantime blocks the execution.
|
|
406
|
+
const policy = await readPolicy(config.configFile)
|
|
407
|
+
if (plan.capability && !allows(policy, plan.envKey, plan.capability)) {
|
|
408
|
+
const label = CAPABILITIES[plan.capability]?.label ?? plan.capability
|
|
409
|
+
return json(403, {
|
|
410
|
+
error: `环境 ${plan.envKey} 未授权「${label}」,不能执行这个计划`,
|
|
411
|
+
hint: '在设置页「权限」里勾选该能力后再试。',
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const result = await executePlan(config, plan, undefined, { force: body?.force === true })
|
|
416
|
+
await deletePlan(config, planId)
|
|
417
|
+
return json(200, result)
|
|
418
|
+
} catch (error) {
|
|
419
|
+
return failure(error)
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
}))
|
|
423
|
+
|
|
424
|
+
disposers.push(fetch.register({
|
|
425
|
+
path: `${BASE}/write/publish`,
|
|
426
|
+
methods: ['POST'],
|
|
427
|
+
requestBody: 'buffered',
|
|
428
|
+
async fetch(request) {
|
|
429
|
+
try {
|
|
430
|
+
const guard = requiresBrowserRequest(request)
|
|
431
|
+
if (guard) return guard
|
|
432
|
+
const body = await request.json().catch(() => ({}))
|
|
433
|
+
const planId = String(body?.planId ?? '').trim()
|
|
434
|
+
if (!planId) return json(400, { error: '缺少 planId' })
|
|
435
|
+
|
|
436
|
+
const config = await loadConfig(state.rawConfig)
|
|
437
|
+
const entry = (await listChanges(config, 100)).find((item) => item.planId === planId)
|
|
438
|
+
if (!entry) return json(404, { error: '没有找到这个计划的执行记录' })
|
|
439
|
+
if (!(entry.applied?.length > 0)) return json(400, { error: '这个计划没有成功的写入,不需要发布' })
|
|
440
|
+
|
|
441
|
+
// Publishing the result of a write is still part of that write: if the
|
|
442
|
+
// permission was revoked after the plan ran, the person must not be able
|
|
443
|
+
// to push the change live from the page.
|
|
444
|
+
const policyPath = config.configFile
|
|
445
|
+
const policy = policyPath ? await readPolicy(policyPath) : { environments: {} }
|
|
446
|
+
if (entry.capability && !allows(policy, entry.envKey, entry.capability)) {
|
|
447
|
+
return json(403, {
|
|
448
|
+
error: `环境 ${entry.envKey} 未授权 ${entry.capability},不能发布这次改动`,
|
|
449
|
+
hint: '先到「权限」里重新授权,或在环境里手工发布。',
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const scope = publishTargets(entry.applied)
|
|
454
|
+
const startedAt = new Date().toISOString()
|
|
455
|
+
const outcome = await publishNow(config, entry.envKey, scope, { force: body?.force === true })
|
|
456
|
+
const publish = {
|
|
457
|
+
planId,
|
|
458
|
+
envKey: entry.envKey,
|
|
459
|
+
startedAt,
|
|
460
|
+
finishedAt: new Date().toISOString(),
|
|
461
|
+
targets: scope,
|
|
462
|
+
republished: true,
|
|
463
|
+
ok: outcome.ok,
|
|
464
|
+
mode: outcome.mode,
|
|
465
|
+
...(outcome.error ? { error: outcome.error } : {}),
|
|
466
|
+
}
|
|
467
|
+
await writePublishReport(config, planId, publish)
|
|
468
|
+
return json(200, { publish })
|
|
469
|
+
} catch (error) {
|
|
470
|
+
return failure(error)
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
}))
|
|
474
|
+
|
|
475
|
+
disposers.push(fetch.register({
|
|
476
|
+
path: `${BASE}/write/cancel`,
|
|
477
|
+
methods: ['POST'],
|
|
478
|
+
requestBody: 'buffered',
|
|
479
|
+
async fetch(request) {
|
|
480
|
+
try {
|
|
481
|
+
const guard = requiresBrowserRequest(request)
|
|
482
|
+
if (guard) return guard
|
|
483
|
+
const body = await request.json().catch(() => ({}))
|
|
484
|
+
const planId = String(body?.planId ?? '').trim()
|
|
485
|
+
if (!planId) return json(400, { error: '缺少 planId' })
|
|
486
|
+
const config = await loadConfig(state.rawConfig)
|
|
487
|
+
const plan = await readPlan(config, planId)
|
|
488
|
+
if (!plan) return json(404, { error: '计划不存在或已被取消' })
|
|
489
|
+
// Cancelling a running plan would hide writes that are still landing: the
|
|
490
|
+
// execution loop has already read the operations it is applying.
|
|
491
|
+
if (plan.status && plan.status !== 'pending') {
|
|
492
|
+
const label = plan.status === 'running'
|
|
493
|
+
? '正在执行,或上次执行中途中断'
|
|
494
|
+
: plan.status === 'failed' ? '上次执行失败' : '已经执行过'
|
|
495
|
+
return json(409, {
|
|
496
|
+
error: `计划${label},不能取消`,
|
|
497
|
+
hint: '先到 .d365/changes 核对执行记录。',
|
|
498
|
+
status: plan.status,
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
const removed = await deletePlan(config, planId)
|
|
502
|
+
return json(200, { removed })
|
|
503
|
+
} catch (error) {
|
|
504
|
+
return failure(error)
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
}))
|
|
508
|
+
|
|
509
|
+
return () => {
|
|
510
|
+
for (const dispose of disposers) Promise.resolve(dispose()).catch(() => {})
|
|
511
|
+
}
|
|
512
|
+
}
|