@hunan36/dsh-ssh-helper 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +330 -2
- package/README.zh.md +331 -0
- package/client.js +2786 -0
- package/cordis.patch.yml +13 -0
- package/docs/screenshot-accounts.png +0 -0
- package/docs/screenshot-activity.png +0 -0
- package/docs/screenshot-panel.png +0 -0
- package/docs/screenshot-resource-detail.png +0 -0
- package/host/accounts.js +688 -0
- package/host/api.js +440 -0
- package/host/atomic.js +160 -0
- package/host/discover.js +440 -0
- package/host/gate.js +264 -0
- package/host/history.js +276 -0
- package/host/prompt.js +103 -0
- package/host/resources.js +604 -0
- package/host/shell.js +194 -0
- package/host/ssh.js +460 -0
- package/host/tools.js +1086 -0
- package/host/update.js +992 -0
- package/host/upload.js +399 -0
- package/icon.svg +19 -0
- package/index.js +141 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +75 -4
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent deployment resource store.
|
|
3
|
+
*
|
|
4
|
+
* A resource is one service directory on one SSH account: where it lives, how
|
|
5
|
+
* to update/restart/verify it, and what was discovered about it. Resources hold
|
|
6
|
+
* no credentials; the public view adds `serverName` from the account store.
|
|
7
|
+
* The file is written atomically to `resources.json` with mode `0600`.
|
|
8
|
+
*
|
|
9
|
+
* @module host/resources
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from 'node:crypto'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { createJsonFile } from './atomic.js'
|
|
15
|
+
import { validateRemotePath } from './shell.js'
|
|
16
|
+
|
|
17
|
+
/** File name inside the plugin data directory. */
|
|
18
|
+
const DATA_FILE = 'resources.json'
|
|
19
|
+
/** Maximum display name length. */
|
|
20
|
+
const NAME_MAX_LENGTH = 200
|
|
21
|
+
/** Maximum note length. */
|
|
22
|
+
const NOTE_MAX_LENGTH = 500
|
|
23
|
+
/** Maximum command length. */
|
|
24
|
+
const COMMAND_MAX_LENGTH = 8192
|
|
25
|
+
/** Maximum number of tags. */
|
|
26
|
+
const TAGS_MAX = 32
|
|
27
|
+
/** Default backup retention. */
|
|
28
|
+
const DEFAULT_BACKUP_KEEP = 5
|
|
29
|
+
/** Default step timeout in seconds. */
|
|
30
|
+
const DEFAULT_TIMEOUT_SECONDS = 600
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build one tagged error.
|
|
34
|
+
* @param {string} code - machine-readable code.
|
|
35
|
+
* @param {string} message - Chinese message.
|
|
36
|
+
* @param {unknown} [cause] - underlying failure.
|
|
37
|
+
* @returns {Error & { code: string }} tagged error.
|
|
38
|
+
*/
|
|
39
|
+
function taggedError(code, message, cause) {
|
|
40
|
+
const error = new Error(message)
|
|
41
|
+
error.code = code
|
|
42
|
+
if (cause !== undefined) error.cause = cause
|
|
43
|
+
return error
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @returns {Error} invalid-input error. */
|
|
47
|
+
function invalid(message) {
|
|
48
|
+
return taggedError('invalid-input', message)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @returns {Error} conflict error. */
|
|
52
|
+
function conflict(message) {
|
|
53
|
+
return taggedError('conflict', message)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @returns {Error} not-found error. */
|
|
57
|
+
function notFound(message) {
|
|
58
|
+
return taggedError('not-found', message)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @returns {Error} storage error. */
|
|
62
|
+
function storageError(cause) {
|
|
63
|
+
return taggedError('storage', '无法保存资源数据', cause)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {unknown} value @returns {boolean} whether the value is a plain object. */
|
|
67
|
+
function isObject(value) {
|
|
68
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pick an incoming value, falling back to the stored value.
|
|
73
|
+
* @param {object} draft - incoming draft.
|
|
74
|
+
* @param {string} key - field name.
|
|
75
|
+
* @param {object | undefined} existing - stored record.
|
|
76
|
+
* @returns {unknown} effective value.
|
|
77
|
+
*/
|
|
78
|
+
function pick(draft, key, existing) {
|
|
79
|
+
const value = draft[key]
|
|
80
|
+
if (value !== undefined) return value
|
|
81
|
+
if (existing === undefined) return undefined
|
|
82
|
+
return existing[key]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read an optional, trimmed string field.
|
|
87
|
+
* @param {unknown} value - raw value.
|
|
88
|
+
* @param {string} label - field label.
|
|
89
|
+
* @param {number} maxLength - maximum length.
|
|
90
|
+
* @returns {string | undefined} trimmed value or undefined.
|
|
91
|
+
*/
|
|
92
|
+
function readText(value, label, maxLength) {
|
|
93
|
+
if (value === undefined || value === null) return undefined
|
|
94
|
+
if (typeof value !== 'string') throw invalid(`${label}格式无效`)
|
|
95
|
+
const trimmed = value.trim()
|
|
96
|
+
if (trimmed === '') return undefined
|
|
97
|
+
if (trimmed.length > maxLength) throw invalid(`${label}最多 ${maxLength} 个字符`)
|
|
98
|
+
return trimmed
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Read a required, non-empty string field.
|
|
103
|
+
* @param {unknown} value - raw value.
|
|
104
|
+
* @param {string} label - field label.
|
|
105
|
+
* @param {number} maxLength - maximum length.
|
|
106
|
+
* @returns {string} trimmed value.
|
|
107
|
+
*/
|
|
108
|
+
function readRequiredText(value, label, maxLength) {
|
|
109
|
+
const text = readText(value, label, maxLength)
|
|
110
|
+
if (text === undefined) throw invalid(`${label}不能为空`)
|
|
111
|
+
return text
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read a boolean field with a fallback.
|
|
116
|
+
* @param {unknown} value - raw value.
|
|
117
|
+
* @param {string} label - field label.
|
|
118
|
+
* @param {boolean} fallback - default.
|
|
119
|
+
* @returns {boolean} effective value.
|
|
120
|
+
*/
|
|
121
|
+
function readBoolean(value, label, fallback) {
|
|
122
|
+
if (value === undefined || value === null) return fallback
|
|
123
|
+
if (typeof value !== 'boolean') throw invalid(`${label}必须是布尔值`)
|
|
124
|
+
return value
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Read a clamped integer field.
|
|
129
|
+
* @param {unknown} value - raw value.
|
|
130
|
+
* @param {string} label - field label.
|
|
131
|
+
* @param {number} min - lower bound.
|
|
132
|
+
* @param {number} max - upper bound.
|
|
133
|
+
* @param {number} fallback - default.
|
|
134
|
+
* @returns {number} clamped integer.
|
|
135
|
+
*/
|
|
136
|
+
function readClampedInteger(value, label, min, max, fallback) {
|
|
137
|
+
if (value === undefined || value === null || value === '') return fallback
|
|
138
|
+
const numeric = typeof value === 'string' ? Number(value) : value
|
|
139
|
+
if (typeof numeric !== 'number' || !Number.isFinite(numeric)) throw invalid(`${label}必须是数字`)
|
|
140
|
+
return Math.min(max, Math.max(min, Math.round(numeric)))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Normalize the tag list.
|
|
145
|
+
* @param {unknown} value - raw tags.
|
|
146
|
+
* @param {string[]} [fallback] - values used when the field is absent.
|
|
147
|
+
* @returns {string[]} normalized tags.
|
|
148
|
+
*/
|
|
149
|
+
function readTags(value, fallback = []) {
|
|
150
|
+
if (value === undefined || value === null) return fallback.slice()
|
|
151
|
+
if (!Array.isArray(value)) throw invalid('标签必须是字符串数组')
|
|
152
|
+
const tags = []
|
|
153
|
+
for (const entry of value) {
|
|
154
|
+
if (typeof entry !== 'string') throw invalid('标签必须是字符串数组')
|
|
155
|
+
const trimmed = entry.trim()
|
|
156
|
+
if (trimmed === '' || tags.includes(trimmed)) continue
|
|
157
|
+
tags.push(trimmed)
|
|
158
|
+
if (tags.length >= TAGS_MAX) break
|
|
159
|
+
}
|
|
160
|
+
return tags
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Read an array of strings for a discovered fact.
|
|
165
|
+
* @param {unknown} value - raw value.
|
|
166
|
+
* @param {number} maxItems - maximum retained items.
|
|
167
|
+
* @returns {string[]} strings.
|
|
168
|
+
*/
|
|
169
|
+
function readStringArray(value, maxItems) {
|
|
170
|
+
if (!Array.isArray(value)) return []
|
|
171
|
+
const items = []
|
|
172
|
+
for (const entry of value) {
|
|
173
|
+
if (typeof entry !== 'string') continue
|
|
174
|
+
const trimmed = entry.trim()
|
|
175
|
+
if (trimmed === '' || items.includes(trimmed)) continue
|
|
176
|
+
items.push(trimmed)
|
|
177
|
+
if (items.length >= maxItems) break
|
|
178
|
+
}
|
|
179
|
+
return items
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Normalize the optional `discovered` snapshot from an inspect call.
|
|
184
|
+
* @param {unknown} value - raw discovered value.
|
|
185
|
+
* @param {object | undefined} existing - stored discovered value.
|
|
186
|
+
* @returns {object | null} sanitized snapshot or null.
|
|
187
|
+
*/
|
|
188
|
+
function readDiscovered(value, existing) {
|
|
189
|
+
if (value === undefined) return existing === undefined ? null : existing
|
|
190
|
+
if (value === null) return null
|
|
191
|
+
if (!isObject(value)) throw invalid('采集信息格式无效')
|
|
192
|
+
const snapshot = {}
|
|
193
|
+
if (typeof value.os === 'string' && value.os.trim() !== '') snapshot.os = value.os.trim()
|
|
194
|
+
if (typeof value.user === 'string' && value.user.trim() !== '') snapshot.user = value.user.trim()
|
|
195
|
+
if (typeof value.sizeKB === 'number' && Number.isFinite(value.sizeKB) && value.sizeKB >= 0) snapshot.sizeKB = value.sizeKB
|
|
196
|
+
snapshot.markers = readStringArray(value.markers, 200)
|
|
197
|
+
if (typeof value.gitRemote === 'string') snapshot.gitRemote = value.gitRemote
|
|
198
|
+
if (typeof value.gitBranch === 'string') snapshot.gitBranch = value.gitBranch
|
|
199
|
+
snapshot.serviceUnits = readStringArray(value.serviceUnits, 200)
|
|
200
|
+
snapshot.inspectedAt =
|
|
201
|
+
typeof value.inspectedAt === 'string' && value.inspectedAt !== '' ? value.inspectedAt : new Date().toISOString()
|
|
202
|
+
return snapshot
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Whether a backup directory lies inside the app directory.
|
|
207
|
+
* @param {string} appDir - normalized app directory.
|
|
208
|
+
* @param {string} backupDir - normalized backup directory.
|
|
209
|
+
* @returns {boolean} containment.
|
|
210
|
+
*/
|
|
211
|
+
function isInside(appDir, backupDir) {
|
|
212
|
+
return backupDir === appDir || backupDir.startsWith(`${appDir}/`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Build a complete internal resource record.
|
|
217
|
+
* @param {object} draft - create/update payload.
|
|
218
|
+
* @param {object | undefined} existing - stored record.
|
|
219
|
+
* @param {object} accounts - account store.
|
|
220
|
+
* @returns {object} complete record.
|
|
221
|
+
*/
|
|
222
|
+
function buildRecord(draft, existing, accounts) {
|
|
223
|
+
if (!isObject(draft)) throw invalid('请求内容无效')
|
|
224
|
+
|
|
225
|
+
let name
|
|
226
|
+
const rawName = pick(draft, 'name', existing)
|
|
227
|
+
if (typeof rawName === 'string') {
|
|
228
|
+
const trimmed = rawName.trim()
|
|
229
|
+
name = trimmed === '' ? (existing && existing.name) : trimmed
|
|
230
|
+
} else if (rawName === undefined || rawName === null) {
|
|
231
|
+
name = existing && existing.name
|
|
232
|
+
} else {
|
|
233
|
+
throw invalid('资源名称格式无效')
|
|
234
|
+
}
|
|
235
|
+
if (typeof name !== 'string' || name === '') throw invalid('资源名称不能为空')
|
|
236
|
+
if (name.length > NAME_MAX_LENGTH) throw invalid(`资源名称最多 ${NAME_MAX_LENGTH} 个字符`)
|
|
237
|
+
// A resource name is a label shown in the approval prompt, never a script.
|
|
238
|
+
// Line breaks would let the name forge prompt chrome (`Backup: disabled.`),
|
|
239
|
+
// so they are rejected here rather than sanitised at every display site.
|
|
240
|
+
if (/[\r\n\u2028\u2029]/.test(name)) throw invalid('资源名称不能包含换行')
|
|
241
|
+
|
|
242
|
+
let serverId
|
|
243
|
+
if (typeof draft.serverId === 'string' && draft.serverId.trim() !== '') {
|
|
244
|
+
serverId = draft.serverId.trim()
|
|
245
|
+
} else if (typeof draft.server === 'string' && draft.server.trim() !== '') {
|
|
246
|
+
const account = accounts.find(draft.server.trim())
|
|
247
|
+
if (!account) throw invalid('服务器不存在')
|
|
248
|
+
serverId = account.id
|
|
249
|
+
} else if (existing && typeof existing.serverId === 'string' && existing.serverId !== '') {
|
|
250
|
+
serverId = existing.serverId
|
|
251
|
+
}
|
|
252
|
+
if (typeof serverId !== 'string' || serverId === '') throw invalid('请选择服务器')
|
|
253
|
+
if (!accounts.get(serverId)) throw invalid('服务器不存在')
|
|
254
|
+
|
|
255
|
+
const rawAppDir = pick(draft, 'appDir', existing)
|
|
256
|
+
if (rawAppDir === undefined || rawAppDir === null || rawAppDir === '') throw invalid('应用目录不能为空')
|
|
257
|
+
const appDir = validateRemotePath(rawAppDir)
|
|
258
|
+
|
|
259
|
+
const rawUpdate = pick(draft, 'updateCommand', existing)
|
|
260
|
+
const updateCommand = readRequiredText(rawUpdate, '更新命令', COMMAND_MAX_LENGTH)
|
|
261
|
+
|
|
262
|
+
const restartCommand = readText(pick(draft, 'restartCommand', existing), '重启命令', COMMAND_MAX_LENGTH)
|
|
263
|
+
const verifyCommand = readText(pick(draft, 'verifyCommand', existing), '校验命令', COMMAND_MAX_LENGTH)
|
|
264
|
+
const serviceName = readText(pick(draft, 'serviceName', existing), '服务名称', NAME_MAX_LENGTH)
|
|
265
|
+
|
|
266
|
+
const backupEnabled = readBoolean(pick(draft, 'backupEnabled', existing), '备份开关', true)
|
|
267
|
+
const rawBackupDir = readText(pick(draft, 'backupDir', existing), '备份目录', 1024)
|
|
268
|
+
const backupDir = rawBackupDir === undefined ? undefined : validateRemotePath(rawBackupDir)
|
|
269
|
+
if (backupDir !== undefined && isInside(appDir, backupDir)) throw invalid('备份目录不能位于应用目录内部')
|
|
270
|
+
|
|
271
|
+
const backupKeep = readClampedInteger(pick(draft, 'backupKeep', existing), '备份保留数量', 1, 50, DEFAULT_BACKUP_KEEP)
|
|
272
|
+
const timeoutSeconds = readClampedInteger(
|
|
273
|
+
pick(draft, 'timeoutSeconds', existing),
|
|
274
|
+
'超时时间',
|
|
275
|
+
30,
|
|
276
|
+
3600,
|
|
277
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
const rawNote = pick(draft, 'note', existing)
|
|
281
|
+
let note
|
|
282
|
+
if (rawNote !== undefined && rawNote !== null) {
|
|
283
|
+
if (typeof rawNote !== 'string') throw invalid('备注格式无效')
|
|
284
|
+
note = rawNote.trim()
|
|
285
|
+
if (note.length > NOTE_MAX_LENGTH) throw invalid(`备注最多 ${NOTE_MAX_LENGTH} 个字符`)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const tags = readTags(pick(draft, 'tags', existing), existing && Array.isArray(existing.tags) ? existing.tags : [])
|
|
289
|
+
const discovered = readDiscovered(pick(draft, 'discovered', existing), existing ? existing.discovered : undefined)
|
|
290
|
+
|
|
291
|
+
const now = new Date().toISOString()
|
|
292
|
+
const record = {
|
|
293
|
+
id: existing && typeof existing.id === 'string' ? existing.id : randomUUID(),
|
|
294
|
+
name,
|
|
295
|
+
serverId,
|
|
296
|
+
appDir,
|
|
297
|
+
updateCommand,
|
|
298
|
+
backupEnabled,
|
|
299
|
+
backupKeep,
|
|
300
|
+
timeoutSeconds,
|
|
301
|
+
tags,
|
|
302
|
+
discovered: discovered === undefined ? null : discovered,
|
|
303
|
+
createdAt: existing && typeof existing.createdAt === 'string' ? existing.createdAt : now,
|
|
304
|
+
updatedAt: now,
|
|
305
|
+
}
|
|
306
|
+
if (serviceName !== undefined) record.serviceName = serviceName
|
|
307
|
+
if (restartCommand !== undefined) record.restartCommand = restartCommand
|
|
308
|
+
if (verifyCommand !== undefined) record.verifyCommand = verifyCommand
|
|
309
|
+
if (backupDir !== undefined) record.backupDir = backupDir
|
|
310
|
+
if (note !== undefined && note !== '') record.note = note
|
|
311
|
+
if (existing && typeof existing.lastUpdateAt === 'string') record.lastUpdateAt = existing.lastUpdateAt
|
|
312
|
+
if (existing && typeof existing.lastUpdateStatus === 'string') record.lastUpdateStatus = existing.lastUpdateStatus
|
|
313
|
+
if (existing && typeof existing.lastBackupPath === 'string') record.lastBackupPath = existing.lastBackupPath
|
|
314
|
+
return record
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Project one record to its public view, adding the account display name.
|
|
319
|
+
* @param {object} record - internal record.
|
|
320
|
+
* @param {object} accounts - account store.
|
|
321
|
+
* @returns {object} public view.
|
|
322
|
+
*/
|
|
323
|
+
function toView(record, accounts) {
|
|
324
|
+
const account = accounts.get(record.serverId)
|
|
325
|
+
const view = {
|
|
326
|
+
id: String(record.id ?? ''),
|
|
327
|
+
name: typeof record.name === 'string' ? record.name : '',
|
|
328
|
+
serverId: typeof record.serverId === 'string' ? record.serverId : '',
|
|
329
|
+
serverName: account && typeof account.name === 'string' ? account.name : '',
|
|
330
|
+
appDir: typeof record.appDir === 'string' ? record.appDir : '',
|
|
331
|
+
updateCommand: typeof record.updateCommand === 'string' ? record.updateCommand : '',
|
|
332
|
+
backupEnabled: record.backupEnabled === true,
|
|
333
|
+
backupKeep: Number.isInteger(record.backupKeep) ? record.backupKeep : DEFAULT_BACKUP_KEEP,
|
|
334
|
+
timeoutSeconds: Number.isInteger(record.timeoutSeconds) ? record.timeoutSeconds : DEFAULT_TIMEOUT_SECONDS,
|
|
335
|
+
tags: Array.isArray(record.tags) ? record.tags.filter((entry) => typeof entry === 'string') : [],
|
|
336
|
+
discovered: record.discovered === null || record.discovered === undefined ? null : record.discovered,
|
|
337
|
+
createdAt: typeof record.createdAt === 'string' ? record.createdAt : '',
|
|
338
|
+
updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : '',
|
|
339
|
+
}
|
|
340
|
+
if (typeof record.serviceName === 'string' && record.serviceName !== '') view.serviceName = record.serviceName
|
|
341
|
+
if (typeof record.restartCommand === 'string' && record.restartCommand !== '') view.restartCommand = record.restartCommand
|
|
342
|
+
if (typeof record.verifyCommand === 'string' && record.verifyCommand !== '') view.verifyCommand = record.verifyCommand
|
|
343
|
+
if (typeof record.backupDir === 'string' && record.backupDir !== '') view.backupDir = record.backupDir
|
|
344
|
+
if (typeof record.note === 'string' && record.note !== '') view.note = record.note
|
|
345
|
+
if (typeof record.lastUpdateAt === 'string' && record.lastUpdateAt !== '') view.lastUpdateAt = record.lastUpdateAt
|
|
346
|
+
if (typeof record.lastUpdateStatus === 'string' && record.lastUpdateStatus !== '') view.lastUpdateStatus = record.lastUpdateStatus
|
|
347
|
+
if (typeof record.lastBackupPath === 'string' && record.lastBackupPath !== '') view.lastBackupPath = record.lastBackupPath
|
|
348
|
+
return view
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Whether one parsed entry is a usable persisted resource.
|
|
353
|
+
* @param {unknown} entry - parsed entry.
|
|
354
|
+
* @returns {boolean} validity.
|
|
355
|
+
*/
|
|
356
|
+
function isPersistedRecord(entry) {
|
|
357
|
+
if (!isObject(entry)) return false
|
|
358
|
+
if (typeof entry.id !== 'string' || entry.id === '') return false
|
|
359
|
+
if (typeof entry.name !== 'string' || entry.name === '') return false
|
|
360
|
+
if (typeof entry.serverId !== 'string' || entry.serverId === '') return false
|
|
361
|
+
if (typeof entry.appDir !== 'string' || entry.appDir === '') return false
|
|
362
|
+
if (typeof entry.updateCommand !== 'string' || entry.updateCommand.trim() === '') return false
|
|
363
|
+
return true
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Keep only known fields of a persisted resource.
|
|
368
|
+
* @param {object} entry - parsed record.
|
|
369
|
+
* @returns {object} sanitized record.
|
|
370
|
+
*/
|
|
371
|
+
function sanitizePersisted(entry) {
|
|
372
|
+
const record = {
|
|
373
|
+
id: entry.id,
|
|
374
|
+
name: entry.name,
|
|
375
|
+
serverId: entry.serverId,
|
|
376
|
+
appDir: entry.appDir,
|
|
377
|
+
updateCommand: entry.updateCommand,
|
|
378
|
+
backupEnabled: entry.backupEnabled !== false,
|
|
379
|
+
backupKeep: Number.isInteger(entry.backupKeep) ? Math.min(50, Math.max(1, entry.backupKeep)) : DEFAULT_BACKUP_KEEP,
|
|
380
|
+
timeoutSeconds: Number.isInteger(entry.timeoutSeconds)
|
|
381
|
+
? Math.min(3600, Math.max(30, entry.timeoutSeconds))
|
|
382
|
+
: DEFAULT_TIMEOUT_SECONDS,
|
|
383
|
+
tags: Array.isArray(entry.tags) ? entry.tags.filter((tag) => typeof tag === 'string') : [],
|
|
384
|
+
discovered: isObject(entry.discovered) || entry.discovered === null ? entry.discovered : null,
|
|
385
|
+
createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : new Date().toISOString(),
|
|
386
|
+
updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : new Date().toISOString(),
|
|
387
|
+
}
|
|
388
|
+
for (const key of ['serviceName', 'restartCommand', 'verifyCommand', 'backupDir', 'note', 'lastUpdateAt', 'lastUpdateStatus', 'lastBackupPath']) {
|
|
389
|
+
if (typeof entry[key] === 'string' && entry[key] !== '') record[key] = entry[key]
|
|
390
|
+
}
|
|
391
|
+
return record
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Create the resource store.
|
|
396
|
+
* @param {{ dir: string, accounts: object }} options - data directory and account store.
|
|
397
|
+
* @returns {{
|
|
398
|
+
* dataDir: () => string,
|
|
399
|
+
* load: () => void,
|
|
400
|
+
* list: (options?: { server?: string }) => object[],
|
|
401
|
+
* get: (id: string) => object | undefined,
|
|
402
|
+
* find: (query: string) => object | undefined,
|
|
403
|
+
* save: (draft: object) => Promise<object>,
|
|
404
|
+
* remove: (id: string) => Promise<boolean>,
|
|
405
|
+
* toView: (record: object) => object | undefined,
|
|
406
|
+
* markUpdated: (id: string, patch: { at?: string, status?: string, backupPath?: string }) => Promise<object | undefined>,
|
|
407
|
+
* }} store API.
|
|
408
|
+
*/
|
|
409
|
+
export function createResourceStore({ dir, accounts } = {}) {
|
|
410
|
+
if (typeof dir !== 'string' || dir.trim() === '') throw new TypeError('createResourceStore: dir is required')
|
|
411
|
+
if (!accounts || typeof accounts.get !== 'function' || typeof accounts.find !== 'function') {
|
|
412
|
+
throw new TypeError('createResourceStore: accounts store is required')
|
|
413
|
+
}
|
|
414
|
+
const resolvedDir = path.resolve(dir)
|
|
415
|
+
const file = createJsonFile({ dir: resolvedDir, filename: DATA_FILE })
|
|
416
|
+
|
|
417
|
+
/** @type {object[]} internal records. */
|
|
418
|
+
let records = []
|
|
419
|
+
let loaded = false
|
|
420
|
+
/** @type {Promise<unknown>} tail of the serialized mutation chain. */
|
|
421
|
+
let mutationTail = Promise.resolve()
|
|
422
|
+
|
|
423
|
+
/** Read the data file once; never throws. */
|
|
424
|
+
function load() {
|
|
425
|
+
if (loaded) return
|
|
426
|
+
loaded = true
|
|
427
|
+
const parsed = file.read()
|
|
428
|
+
records = Array.isArray(parsed) ? parsed.filter(isPersistedRecord).map(sanitizePersisted) : []
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Ensure the lazy read happened. */
|
|
432
|
+
function ensureLoaded() {
|
|
433
|
+
if (!loaded) load()
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* @param {string} id - record id.
|
|
438
|
+
* @returns {object | undefined} internal record.
|
|
439
|
+
*/
|
|
440
|
+
function rawById(id) {
|
|
441
|
+
if (typeof id !== 'string' || id === '') return undefined
|
|
442
|
+
return records.find((record) => record.id === id)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Reject a display name used by another resource (case-insensitive).
|
|
447
|
+
* @param {string} name - candidate name.
|
|
448
|
+
* @param {string} selfId - id of the record being written.
|
|
449
|
+
* @throws {Error} conflict when another resource owns the name.
|
|
450
|
+
*/
|
|
451
|
+
function assertNameAvailable(name, selfId) {
|
|
452
|
+
const wanted = name.toLowerCase()
|
|
453
|
+
const clash = records.find((record) => record.id !== selfId && typeof record.name === 'string' && record.name.toLowerCase() === wanted)
|
|
454
|
+
if (clash) throw conflict('资源名称已存在')
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Serialize one mutation on top of earlier mutations.
|
|
459
|
+
* @template T
|
|
460
|
+
* @param {() => Promise<T>} task - mutation.
|
|
461
|
+
* @returns {Promise<T>} outcome.
|
|
462
|
+
*/
|
|
463
|
+
function enqueue(task) {
|
|
464
|
+
const run = mutationTail.then(task, task)
|
|
465
|
+
mutationTail = run.then(
|
|
466
|
+
() => undefined,
|
|
467
|
+
() => undefined,
|
|
468
|
+
)
|
|
469
|
+
return run
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** @returns {string} resolved data directory. */
|
|
473
|
+
function dataDir() {
|
|
474
|
+
return resolvedDir
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* List resources, optionally filtered by account id/name.
|
|
479
|
+
* @param {{ server?: string }} [options] - filter.
|
|
480
|
+
* @returns {object[]} public views sorted by name.
|
|
481
|
+
*/
|
|
482
|
+
function list({ server } = {}) {
|
|
483
|
+
ensureLoaded()
|
|
484
|
+
/** @type {string | undefined} */
|
|
485
|
+
let filterId
|
|
486
|
+
if (typeof server === 'string' && server.trim() !== '') {
|
|
487
|
+
const found = accounts.find(server.trim())
|
|
488
|
+
filterId = found ? found.id : server.trim()
|
|
489
|
+
}
|
|
490
|
+
const selected = filterId === undefined ? records : records.filter((record) => record.serverId === filterId)
|
|
491
|
+
return selected
|
|
492
|
+
.slice()
|
|
493
|
+
.sort((left, right) => String(left.name).localeCompare(String(right.name)))
|
|
494
|
+
.map((record) => toView(record, accounts))
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* @param {string} id - record id.
|
|
499
|
+
* @returns {object | undefined} public view.
|
|
500
|
+
*/
|
|
501
|
+
function get(id) {
|
|
502
|
+
ensureLoaded()
|
|
503
|
+
const record = rawById(id)
|
|
504
|
+
return record === undefined ? undefined : toView(record, accounts)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Resolve a resource by id, exact name (case-insensitive), then a unique name
|
|
509
|
+
* prefix.
|
|
510
|
+
* @param {string} query - id or name text.
|
|
511
|
+
* @returns {object | undefined} public view.
|
|
512
|
+
*/
|
|
513
|
+
function find(query) {
|
|
514
|
+
ensureLoaded()
|
|
515
|
+
if (typeof query !== 'string') return undefined
|
|
516
|
+
const text = query.trim()
|
|
517
|
+
if (text === '') return undefined
|
|
518
|
+
const byId = records.find((record) => record.id === text)
|
|
519
|
+
if (byId) return toView(byId, accounts)
|
|
520
|
+
const lower = text.toLowerCase()
|
|
521
|
+
const byName = records.find((record) => typeof record.name === 'string' && record.name.toLowerCase() === lower)
|
|
522
|
+
if (byName) return toView(byName, accounts)
|
|
523
|
+
const byPrefix = records.filter((record) => typeof record.name === 'string' && record.name.toLowerCase().startsWith(lower))
|
|
524
|
+
if (byPrefix.length === 1) return toView(byPrefix[0], accounts)
|
|
525
|
+
return undefined
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Create or update one resource.
|
|
530
|
+
* @param {object} draft - flat payload; `id` updates an existing resource.
|
|
531
|
+
* @returns {Promise<object>} public view.
|
|
532
|
+
*/
|
|
533
|
+
function save(draft) {
|
|
534
|
+
ensureLoaded()
|
|
535
|
+
return enqueue(async () => {
|
|
536
|
+
if (!isObject(draft)) throw invalid('请求内容无效')
|
|
537
|
+
const id = typeof draft.id === 'string' && draft.id.trim() !== '' ? draft.id.trim() : ''
|
|
538
|
+
const existing = id === '' ? undefined : rawById(id)
|
|
539
|
+
if (id !== '' && existing === undefined) throw notFound('资源不存在')
|
|
540
|
+
const record = buildRecord(draft, existing, accounts)
|
|
541
|
+
assertNameAvailable(record.name, record.id)
|
|
542
|
+
const previous = records
|
|
543
|
+
records = existing === undefined ? [...records, record] : records.map((entry) => (entry.id === record.id ? record : entry))
|
|
544
|
+
try {
|
|
545
|
+
await file.write(records)
|
|
546
|
+
} catch (error) {
|
|
547
|
+
records = previous
|
|
548
|
+
throw storageError(error)
|
|
549
|
+
}
|
|
550
|
+
return toView(record, accounts)
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Remove one resource.
|
|
556
|
+
* @param {string} id - record id.
|
|
557
|
+
* @returns {Promise<boolean>} whether a record was removed.
|
|
558
|
+
*/
|
|
559
|
+
function remove(id) {
|
|
560
|
+
ensureLoaded()
|
|
561
|
+
return enqueue(async () => {
|
|
562
|
+
if (!rawById(id)) return false
|
|
563
|
+
const previous = records
|
|
564
|
+
records = records.filter((record) => record.id !== id)
|
|
565
|
+
try {
|
|
566
|
+
await file.write(records)
|
|
567
|
+
} catch (error) {
|
|
568
|
+
records = previous
|
|
569
|
+
throw storageError(error)
|
|
570
|
+
}
|
|
571
|
+
return true
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Record the outcome of an update attempt on one resource.
|
|
577
|
+
* @param {string} id - record id.
|
|
578
|
+
* @param {{ at?: string, status?: string, backupPath?: string }} patch - outcome fields.
|
|
579
|
+
* @returns {Promise<object | undefined>} public view, or undefined when absent.
|
|
580
|
+
*/
|
|
581
|
+
function markUpdated(id, patch = {}) {
|
|
582
|
+
ensureLoaded()
|
|
583
|
+
return enqueue(async () => {
|
|
584
|
+
const existing = rawById(id)
|
|
585
|
+
if (!existing) return undefined
|
|
586
|
+
const now = new Date().toISOString()
|
|
587
|
+
const updated = { ...existing, updatedAt: now }
|
|
588
|
+
updated.lastUpdateAt = typeof patch.at === 'string' && patch.at !== '' ? patch.at : now
|
|
589
|
+
if (typeof patch.status === 'string' && patch.status !== '') updated.lastUpdateStatus = patch.status
|
|
590
|
+
if (typeof patch.backupPath === 'string') updated.lastBackupPath = patch.backupPath
|
|
591
|
+
const previous = records
|
|
592
|
+
records = records.map((record) => (record.id === id ? updated : record))
|
|
593
|
+
try {
|
|
594
|
+
await file.write(records)
|
|
595
|
+
} catch (error) {
|
|
596
|
+
records = previous
|
|
597
|
+
throw storageError(error)
|
|
598
|
+
}
|
|
599
|
+
return toView(updated, accounts)
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return { dataDir, load, list, get, find, save, remove, toView: (record) => toView(record, accounts), markUpdated }
|
|
604
|
+
}
|