@jsbot-tellus/core 2.3.0 → 2.5.0

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 (120) hide show
  1. package/README.md +19 -0
  2. package/bot.config.example.json +3 -0
  3. package/dist/app.js +215 -98
  4. package/dist/app.js.map +1 -1
  5. package/dist/core/client.d.ts +1 -0
  6. package/dist/core/client.d.ts.map +1 -1
  7. package/dist/core/client.js +3 -0
  8. package/dist/core/client.js.map +1 -1
  9. package/dist/core/config/schema.d.ts +19 -0
  10. package/dist/core/config/schema.d.ts.map +1 -1
  11. package/dist/core/config/schema.js +37 -0
  12. package/dist/core/config/schema.js.map +1 -1
  13. package/dist/core/dispatch/command.d.ts.map +1 -1
  14. package/dist/core/dispatch/command.js +5 -1
  15. package/dist/core/dispatch/command.js.map +1 -1
  16. package/dist/core/dispatch/router.d.ts +3 -0
  17. package/dist/core/dispatch/router.d.ts.map +1 -1
  18. package/dist/core/dispatch/router.js +14 -1
  19. package/dist/core/dispatch/router.js.map +1 -1
  20. package/dist/core/index.d.ts +2 -2
  21. package/dist/core/index.d.ts.map +1 -1
  22. package/dist/core/index.js.map +1 -1
  23. package/dist/core/kernel.d.ts +8 -0
  24. package/dist/core/kernel.d.ts.map +1 -1
  25. package/dist/core/kernel.js +17 -0
  26. package/dist/core/kernel.js.map +1 -1
  27. package/dist/core/logging/log-buffer.d.ts +27 -0
  28. package/dist/core/logging/log-buffer.d.ts.map +1 -0
  29. package/dist/core/logging/log-buffer.js +71 -0
  30. package/dist/core/logging/log-buffer.js.map +1 -0
  31. package/dist/core/logging/logger.d.ts +3 -0
  32. package/dist/core/logging/logger.d.ts.map +1 -1
  33. package/dist/core/logging/logger.js +6 -3
  34. package/dist/core/logging/logger.js.map +1 -1
  35. package/dist/core/plugin/command-params.d.ts +4 -3
  36. package/dist/core/plugin/command-params.d.ts.map +1 -1
  37. package/dist/core/plugin/command-params.js +122 -3
  38. package/dist/core/plugin/command-params.js.map +1 -1
  39. package/dist/core/storage/session.d.ts +15 -0
  40. package/dist/core/storage/session.d.ts.map +1 -1
  41. package/dist/core/storage/session.js +55 -0
  42. package/dist/core/storage/session.js.map +1 -1
  43. package/dist/core/web/api.d.ts +16 -0
  44. package/dist/core/web/api.d.ts.map +1 -0
  45. package/dist/core/web/api.js +617 -0
  46. package/dist/core/web/api.js.map +1 -0
  47. package/dist/core/web/auth.d.ts +43 -0
  48. package/dist/core/web/auth.d.ts.map +1 -0
  49. package/dist/core/web/auth.js +161 -0
  50. package/dist/core/web/auth.js.map +1 -0
  51. package/dist/core/web/config-store.d.ts +30 -0
  52. package/dist/core/web/config-store.d.ts.map +1 -0
  53. package/dist/core/web/config-store.js +216 -0
  54. package/dist/core/web/config-store.js.map +1 -0
  55. package/dist/core/web/event-feed.d.ts +26 -0
  56. package/dist/core/web/event-feed.d.ts.map +1 -0
  57. package/dist/core/web/event-feed.js +107 -0
  58. package/dist/core/web/event-feed.js.map +1 -0
  59. package/dist/core/web/http.d.ts +39 -0
  60. package/dist/core/web/http.d.ts.map +1 -0
  61. package/dist/core/web/http.js +233 -0
  62. package/dist/core/web/http.js.map +1 -0
  63. package/dist/core/web/index.d.ts +11 -0
  64. package/dist/core/web/index.d.ts.map +1 -0
  65. package/dist/core/web/index.js +6 -0
  66. package/dist/core/web/index.js.map +1 -0
  67. package/dist/core/web/server.d.ts +30 -0
  68. package/dist/core/web/server.d.ts.map +1 -0
  69. package/dist/core/web/server.js +437 -0
  70. package/dist/core/web/server.js.map +1 -0
  71. package/dist/core/web/types.d.ts +84 -0
  72. package/dist/core/web/types.d.ts.map +1 -0
  73. package/dist/core/web/types.js +2 -0
  74. package/dist/core/web/types.js.map +1 -0
  75. package/dist/webui/assets/ActionsView-BDuXBWG1.js +1 -0
  76. package/dist/webui/assets/ActionsView-Ciib5lC6.css +1 -0
  77. package/dist/webui/assets/ConfigView-B3Gv2uRG.js +1 -0
  78. package/dist/webui/assets/ConfigView-CrEpss2y.css +1 -0
  79. package/dist/webui/assets/DashboardView-DR6yCaqs.css +1 -0
  80. package/dist/webui/assets/DashboardView-n9LGPmQL.js +1 -0
  81. package/dist/webui/assets/EventsView-DhO8twWb.js +1 -0
  82. package/dist/webui/assets/EventsView-PMkYKxZ-.css +1 -0
  83. package/dist/webui/assets/KvView-BCz_dOVC.js +1 -0
  84. package/dist/webui/assets/KvView-CaMvbxUz.css +1 -0
  85. package/dist/webui/assets/LoginView-BVnFkwSW.css +1 -0
  86. package/dist/webui/assets/LoginView-Br3_AC5z.js +1 -0
  87. package/dist/webui/assets/LogsView-Ba_12frI.js +1 -0
  88. package/dist/webui/assets/LogsView-NR4L1-XY.css +1 -0
  89. package/dist/webui/assets/MainLayout-D2LTc57u.js +1 -0
  90. package/dist/webui/assets/MainLayout-glfVNbMz.css +1 -0
  91. package/dist/webui/assets/PluginsView-DL9OxGph.css +1 -0
  92. package/dist/webui/assets/PluginsView-vQKBSCU8.js +1 -0
  93. package/dist/webui/assets/SessionsView-DSl37qpo.js +1 -0
  94. package/dist/webui/assets/SessionsView-p-u3JGTV.css +1 -0
  95. package/dist/webui/assets/api-CNzxhTWe.js +3 -0
  96. package/dist/webui/assets/index-CUvpnClx.js +50 -0
  97. package/dist/webui/assets/index-CePlRZX6.css +1 -0
  98. package/dist/webui/index.html +110 -0
  99. package/docs/plugin-api.md +2 -0
  100. package/docs/webui.md +142 -0
  101. package/package.json +14 -3
  102. package/src/app.ts +286 -118
  103. package/src/core/client.ts +4 -0
  104. package/src/core/config/schema.ts +40 -0
  105. package/src/core/dispatch/command.ts +7 -1
  106. package/src/core/dispatch/router.ts +17 -1
  107. package/src/core/index.ts +3 -0
  108. package/src/core/kernel.ts +25 -1
  109. package/src/core/logging/log-buffer.ts +109 -0
  110. package/src/core/logging/logger.ts +7 -3
  111. package/src/core/plugin/command-params.ts +165 -7
  112. package/src/core/storage/session.ts +95 -0
  113. package/src/core/web/api.ts +932 -0
  114. package/src/core/web/auth.ts +245 -0
  115. package/src/core/web/config-store.ts +333 -0
  116. package/src/core/web/event-feed.ts +163 -0
  117. package/src/core/web/http.ts +340 -0
  118. package/src/core/web/index.ts +25 -0
  119. package/src/core/web/server.ts +621 -0
  120. package/src/core/web/types.ts +100 -0
@@ -0,0 +1,245 @@
1
+ import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
2
+ import { promisify } from 'node:util'
3
+ import type { WebUiConfig } from '../config/schema.js'
4
+
5
+ const scryptAsync = promisify(scrypt)
6
+
7
+ export interface AuthSession {
8
+ token: string
9
+ username: string
10
+ expiresAt: number
11
+ }
12
+
13
+ export interface AuthLoginResult {
14
+ ok: boolean
15
+ status: number
16
+ message?: string
17
+ session?: AuthSession
18
+ retryAfterSeconds?: number
19
+ }
20
+
21
+ export interface AuthManagerOptions {
22
+ getConfig(): WebUiConfig
23
+ now?: (() => number) | undefined
24
+ /** Failures allowed per IP inside the window. Defaults to 5. */
25
+ maxFailures?: number | undefined
26
+ /** Failure window. Defaults to 15 minutes. */
27
+ windowMs?: number | undefined
28
+ }
29
+
30
+ interface FailureEntry {
31
+ count: number
32
+ resetAt: number
33
+ }
34
+
35
+ interface PasswordHash {
36
+ salt: Buffer
37
+ hash: Buffer
38
+ }
39
+
40
+ async function hashPassword(password: string, salt: Buffer): Promise<Buffer> {
41
+ return (await scryptAsync(password, salt, 64)) as Buffer
42
+ }
43
+
44
+ export class AuthManager {
45
+ private readonly sessions = new Map<string, AuthSession>()
46
+ private readonly failures = new Map<string, FailureEntry>()
47
+ private cachedHash: (PasswordHash & { password: string }) | null = null
48
+
49
+ constructor(private readonly options: AuthManagerOptions) {}
50
+
51
+ async login(
52
+ username: unknown,
53
+ password: unknown,
54
+ ip: string
55
+ ): Promise<AuthLoginResult> {
56
+ const now = this.now()
57
+ const config = this.options.getConfig()
58
+
59
+ if (!config.enabled) {
60
+ return {
61
+ ok: false,
62
+ status: 503,
63
+ message: 'webui 已禁用,无法登录',
64
+ }
65
+ }
66
+
67
+ if (!this.hasCredentials(config)) {
68
+ return {
69
+ ok: false,
70
+ status: 503,
71
+ message: 'webui 账号密码未配置',
72
+ }
73
+ }
74
+
75
+ const blocked = this.blockedRetryAfter(ip, now)
76
+
77
+ if (blocked !== null) {
78
+ return {
79
+ ok: false,
80
+ status: 429,
81
+ message: '登录尝试过于频繁,请稍后再试',
82
+ retryAfterSeconds: blocked,
83
+ }
84
+ }
85
+
86
+ this.cleanup(now)
87
+
88
+ const providedUsername = typeof username === 'string' ? username : ''
89
+ const providedPassword = typeof password === 'string' ? password : ''
90
+
91
+ const usernameMatches = providedUsername === config.username
92
+ const passwordMatches = await this.comparePassword(
93
+ config.password ?? '',
94
+ providedPassword
95
+ )
96
+
97
+ if (!usernameMatches || !passwordMatches) {
98
+ this.recordFailure(ip, now)
99
+
100
+ return {
101
+ ok: false,
102
+ status: 401,
103
+ message: '用户名或密码错误',
104
+ }
105
+ }
106
+
107
+ this.failures.delete(ip)
108
+
109
+ const token = randomBytes(32).toString('base64url')
110
+ const expiresAt = now + (config.sessionTtlHours ?? 12) * 60 * 60 * 1000
111
+ const session: AuthSession = {
112
+ token,
113
+ username: config.username ?? '',
114
+ expiresAt,
115
+ }
116
+
117
+ this.sessions.set(token, session)
118
+
119
+ return {
120
+ ok: true,
121
+ status: 200,
122
+ session,
123
+ }
124
+ }
125
+
126
+ authenticate(token: string | undefined): AuthSession | null {
127
+ if (!token) return null
128
+
129
+ const session = this.sessions.get(token)
130
+
131
+ if (!session) return null
132
+
133
+ if (session.expiresAt <= this.now()) {
134
+ this.sessions.delete(token)
135
+
136
+ return null
137
+ }
138
+
139
+ return session
140
+ }
141
+
142
+ logout(token: string | undefined): void {
143
+ if (token) this.sessions.delete(token)
144
+ }
145
+
146
+ invalidateAll(): void {
147
+ this.sessions.clear()
148
+ }
149
+
150
+ clearExpired(): void {
151
+ this.cleanup(this.now())
152
+ }
153
+
154
+ private now(): number {
155
+ return (this.options.now ?? Date.now)()
156
+ }
157
+
158
+ private hasCredentials(config: WebUiConfig): boolean {
159
+ return (
160
+ typeof config.username === 'string' &&
161
+ config.username.length > 0 &&
162
+ typeof config.password === 'string' &&
163
+ config.password.length > 0
164
+ )
165
+ }
166
+
167
+ private async comparePassword(
168
+ expected: string,
169
+ provided: string
170
+ ): Promise<boolean> {
171
+ const { salt, hash } = await this.hashFor(expected)
172
+ const providedHash = await hashPassword(provided, salt)
173
+
174
+ return timingSafeEqual(hash, providedHash)
175
+ }
176
+
177
+ private async hashFor(password: string): Promise<PasswordHash> {
178
+ const cached = this.cachedHash
179
+
180
+ if (cached && cached.password === password) return cached
181
+
182
+ const salt = randomBytes(16)
183
+ const hash = await hashPassword(password, salt)
184
+
185
+ this.cachedHash = { password, salt, hash }
186
+
187
+ return { salt, hash }
188
+ }
189
+
190
+ private maxFailures(): number {
191
+ return this.options.maxFailures ?? 5
192
+ }
193
+
194
+ private windowMs(): number {
195
+ return this.options.windowMs ?? 15 * 60 * 1000
196
+ }
197
+
198
+ private blockedRetryAfter(ip: string, now: number): number | null {
199
+ const entry = this.failures.get(ip)
200
+
201
+ if (!entry) return null
202
+
203
+ if (entry.resetAt <= now) {
204
+ this.failures.delete(ip)
205
+
206
+ return null
207
+ }
208
+
209
+ if (entry.count < this.maxFailures()) return null
210
+
211
+ return Math.max(Math.ceil((entry.resetAt - now) / 1000), 1)
212
+ }
213
+
214
+ private recordFailure(ip: string, now: number): void {
215
+ const windowMs = this.windowMs()
216
+ const entry = this.failures.get(ip)
217
+
218
+ if (!entry || entry.resetAt <= now) {
219
+ this.failures.set(ip, { count: 1, resetAt: now + windowMs })
220
+
221
+ return
222
+ }
223
+
224
+ this.failures.set(ip, {
225
+ count: entry.count + 1,
226
+ resetAt: entry.resetAt,
227
+ })
228
+
229
+ if (this.failures.size > 1_000) {
230
+ for (const [key, value] of this.failures) {
231
+ if (value.resetAt <= now) this.failures.delete(key)
232
+ }
233
+ }
234
+ }
235
+
236
+ private cleanup(now: number): void {
237
+ for (const [token, session] of this.sessions) {
238
+ if (session.expiresAt <= now) this.sessions.delete(token)
239
+ }
240
+
241
+ for (const [ip, entry] of this.failures) {
242
+ if (entry.resetAt <= now) this.failures.delete(ip)
243
+ }
244
+ }
245
+ }
@@ -0,0 +1,333 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { copyFile, readFile, rename, stat, writeFile } from 'node:fs/promises'
3
+ import {
4
+ appConfigSchema,
5
+ formatConfigIssues,
6
+ type AppConfig,
7
+ type PluginEnvelope,
8
+ } from '../config/schema.js'
9
+ import { stripPluginEnvelope } from '../plugin/host.js'
10
+ import type { AnyPluginDefinition } from '../plugin/types.js'
11
+ import type {
12
+ ConfigApplyResult,
13
+ ConfigDocument,
14
+ ConfigValidationIssue,
15
+ ConfigValidationResult,
16
+ } from './types.js'
17
+
18
+ export class ConfigStoreError extends Error {
19
+ readonly status: number
20
+ readonly issues: ConfigValidationIssue[]
21
+
22
+ constructor(
23
+ status: number,
24
+ message: string,
25
+ issues: ConfigValidationIssue[] = []
26
+ ) {
27
+ super(message)
28
+ this.name = 'ConfigStoreError'
29
+ this.status = status
30
+ this.issues = issues
31
+ }
32
+ }
33
+
34
+ export interface ConfigStoreOptions {
35
+ configPath: string
36
+ getPlugins(): AnyPluginDefinition[]
37
+ restart(): Promise<boolean>
38
+ }
39
+
40
+ function isRecord(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
42
+ }
43
+
44
+ function asRecord(value: unknown): Record<string, unknown> {
45
+ return isRecord(value) ? value : {}
46
+ }
47
+
48
+ export function mergeSecrets(current: AppConfig, incoming: unknown): unknown {
49
+ if (!isRecord(incoming)) return incoming
50
+
51
+ const next = asRecord(JSON.parse(JSON.stringify(incoming)))
52
+ const accessToken = next['accessToken']
53
+
54
+ if (accessToken === null) {
55
+ delete next['accessToken']
56
+ } else if (accessToken === undefined || accessToken === '') {
57
+ if (current.accessToken !== undefined) {
58
+ next['accessToken'] = current.accessToken
59
+ } else {
60
+ delete next['accessToken']
61
+ }
62
+ }
63
+
64
+ const webui = next['webui']
65
+
66
+ if (isRecord(webui)) {
67
+ const password = webui['password']
68
+
69
+ if (password === null) {
70
+ delete webui['password']
71
+ } else if (password === undefined || password === '') {
72
+ if (current.webui.password !== undefined) {
73
+ webui['password'] = current.webui.password
74
+ } else {
75
+ delete webui['password']
76
+ }
77
+ }
78
+ }
79
+
80
+ return next
81
+ }
82
+
83
+ export interface MaskedConfig {
84
+ config: Record<string, unknown>
85
+ accessTokenSet: boolean
86
+ webuiPasswordSet: boolean
87
+ }
88
+
89
+ export function maskConfig(config: AppConfig): MaskedConfig {
90
+ const masked = asRecord(JSON.parse(JSON.stringify(config)))
91
+ let accessTokenSet = false
92
+ let webuiPasswordSet = false
93
+
94
+ if (typeof masked['accessToken'] === 'string') {
95
+ masked['accessToken'] = ''
96
+ accessTokenSet = true
97
+ }
98
+
99
+ const webui = asRecord(masked['webui'])
100
+
101
+ if (typeof webui['password'] === 'string') {
102
+ webui['password'] = ''
103
+ webuiPasswordSet = true
104
+ }
105
+
106
+ return { config: masked, accessTokenSet, webuiPasswordSet }
107
+ }
108
+
109
+ function issuesFrom(
110
+ zodIssues: Array<{ path: PropertyKey[]; message: string }>
111
+ ): ConfigValidationIssue[] {
112
+ return zodIssues.map((issue) => {
113
+ const path = issue.path
114
+ .map((part) => String(part))
115
+ .filter((part) => part !== '')
116
+ .join('.')
117
+
118
+ return { path, message: issue.message }
119
+ })
120
+ }
121
+
122
+ function validatePlugins(
123
+ config: AppConfig,
124
+ definitions: AnyPluginDefinition[]
125
+ ): ConfigValidationIssue[] {
126
+ const issues: ConfigValidationIssue[] = []
127
+ const byId = new Map(
128
+ definitions.map((definition) => [definition.manifest.id, definition])
129
+ )
130
+
131
+ for (const [id, envelope] of Object.entries(config.plugins)) {
132
+ const definition = byId.get(id)
133
+
134
+ if (!definition) {
135
+ issues.push({
136
+ path: `plugins.${id}`,
137
+ message: `unknown plugin id: ${id}`,
138
+ })
139
+
140
+ continue
141
+ }
142
+
143
+ const enabled =
144
+ (envelope as PluginEnvelope).enabled ??
145
+ definition.manifest.defaultEnabled ??
146
+ false
147
+
148
+ if (!enabled) continue
149
+
150
+ const result = definition.manifest.configSchema.safeParse(
151
+ stripPluginEnvelope(envelope as PluginEnvelope)
152
+ )
153
+
154
+ if (!result.success) {
155
+ issues.push(
156
+ ...issuesFrom(result.error.issues).map((issue) => ({
157
+ path: `plugins.${id}${issue.path.length > 0 ? `.${issue.path}` : ''}`,
158
+ message: issue.message,
159
+ }))
160
+ )
161
+ }
162
+ }
163
+
164
+ return issues
165
+ }
166
+
167
+ async function atomicWrite(filePath: string, raw: string): Promise<void> {
168
+ let mode: number | undefined
169
+
170
+ try {
171
+ mode = (await stat(filePath)).mode & 0o777
172
+ } catch {
173
+ mode = undefined
174
+ }
175
+
176
+ const temporary = `${filePath}.tmp-${randomUUID()}`
177
+
178
+ await writeFile(temporary, raw, {
179
+ encoding: 'utf8',
180
+ ...(mode !== undefined ? { mode } : {}),
181
+ })
182
+
183
+ try {
184
+ await copyFile(filePath, `${filePath}.bak`)
185
+ } catch {
186
+ // Missing source file; nothing to back up.
187
+ }
188
+
189
+ await rename(temporary, filePath)
190
+ }
191
+
192
+ async function restoreRaw(filePath: string, raw: string): Promise<void> {
193
+ const temporary = `${filePath}.tmp-${randomUUID()}`
194
+
195
+ await writeFile(temporary, raw, 'utf8')
196
+ await rename(temporary, filePath)
197
+ }
198
+
199
+ export class ConfigStore {
200
+ private tail: Promise<unknown> = Promise.resolve()
201
+
202
+ constructor(private readonly options: ConfigStoreOptions) {}
203
+
204
+ async read(): Promise<ConfigDocument> {
205
+ let raw: string
206
+
207
+ try {
208
+ raw = await readFile(this.options.configPath, 'utf8')
209
+ } catch (error) {
210
+ throw new ConfigStoreError(
211
+ 500,
212
+ `cannot read config file: ${this.options.configPath}`,
213
+ []
214
+ )
215
+ }
216
+
217
+ let json: unknown
218
+
219
+ try {
220
+ json = JSON.parse(raw) as unknown
221
+ } catch {
222
+ throw new ConfigStoreError(
223
+ 500,
224
+ `invalid JSON in config file: ${this.options.configPath}`
225
+ )
226
+ }
227
+
228
+ const parsed = appConfigSchema.safeParse(json)
229
+
230
+ if (!parsed.success) {
231
+ throw new ConfigStoreError(
232
+ 500,
233
+ 'invalid config file',
234
+ issuesFrom(parsed.error.issues)
235
+ )
236
+ }
237
+
238
+ return {
239
+ path: this.options.configPath,
240
+ raw,
241
+ hash: createHash('sha256').update(raw).digest('hex'),
242
+ config: parsed.data,
243
+ }
244
+ }
245
+
246
+ async validate(value: unknown): Promise<ConfigValidationResult> {
247
+ const document = await this.read()
248
+ const merged = mergeSecrets(document.config, value)
249
+ const parsed = appConfigSchema.safeParse(merged)
250
+
251
+ if (!parsed.success) {
252
+ return {
253
+ ok: false,
254
+ issues: issuesFrom(parsed.error.issues),
255
+ }
256
+ }
257
+
258
+ return {
259
+ ok: true,
260
+ issues: validatePlugins(parsed.data, this.options.getPlugins()),
261
+ config: parsed.data,
262
+ }
263
+ }
264
+
265
+ apply(value: unknown, baseHash?: string): Promise<ConfigApplyResult> {
266
+ return this.serialize(async () => {
267
+ const document = await this.read()
268
+
269
+ if (baseHash !== undefined && baseHash !== document.hash) {
270
+ throw new ConfigStoreError(
271
+ 409,
272
+ '配置文件已被外部修改,请刷新后重试'
273
+ )
274
+ }
275
+
276
+ const merged = mergeSecrets(document.config, value)
277
+ const parsed = appConfigSchema.safeParse(merged)
278
+
279
+ if (!parsed.success) {
280
+ throw new ConfigStoreError(
281
+ 400,
282
+ '配置校验失败',
283
+ issuesFrom(parsed.error.issues)
284
+ )
285
+ }
286
+
287
+ const pluginIssues = validatePlugins(
288
+ parsed.data,
289
+ this.options.getPlugins()
290
+ )
291
+
292
+ if (pluginIssues.length > 0) {
293
+ throw new ConfigStoreError(
294
+ 400,
295
+ '插件配置校验失败',
296
+ pluginIssues
297
+ )
298
+ }
299
+
300
+ const serialized = `${JSON.stringify(parsed.data, null, 4)}\n`
301
+
302
+ await atomicWrite(this.options.configPath, serialized)
303
+
304
+ if (await this.options.restart()) {
305
+ const after = await this.read()
306
+
307
+ return { ok: true, hash: after.hash }
308
+ }
309
+
310
+ await restoreRaw(this.options.configPath, document.raw)
311
+
312
+ const restored = await this.options.restart()
313
+
314
+ return {
315
+ ok: false,
316
+ error: restored
317
+ ? '运行时重启失败,已恢复旧配置'
318
+ : '运行时重启失败,已恢复旧配置,但恢复重启也未成功',
319
+ }
320
+ })
321
+ }
322
+
323
+ private serialize<T>(task: () => Promise<T>): Promise<T> {
324
+ const run = this.tail.then(task, task)
325
+
326
+ this.tail = run.then(
327
+ () => undefined,
328
+ () => undefined
329
+ )
330
+
331
+ return run
332
+ }
333
+ }