dsh-plugin-syncup 0.0.0-stage → 4.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.en.md +183 -0
- package/README.md +362 -2
- package/client.js +1506 -0
- package/cordis.patch.yml +6 -0
- package/icon.svg +26 -0
- package/index.js +354 -0
- package/lib/config.js +330 -0
- package/lib/errors.js +81 -0
- package/lib/github.js +305 -0
- package/lib/http.js +285 -0
- package/lib/jobs.js +107 -0
- package/lib/manifest.js +272 -0
- package/lib/manual.js +85 -0
- package/lib/preview.js +217 -0
- package/lib/pushplan.js +200 -0
- package/lib/spec.js +127 -0
- package/lib/state.js +110 -0
- package/lib/sync.js +1636 -0
- package/lib/versions.js +447 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +52 -3
package/lib/config.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { hostname } from 'node:os'
|
|
2
|
+
import { expandHome } from './spec.js'
|
|
3
|
+
|
|
4
|
+
export const TOKEN_REF = 'GITHUB_TOKEN'
|
|
5
|
+
export const DEFAULT_GIST_NAME = 'dsh-plugin-sync'
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
8
|
+
gistName: DEFAULT_GIST_NAME,
|
|
9
|
+
fileName: 'dsh-plugins.json',
|
|
10
|
+
description: 'DSH plugin inventory',
|
|
11
|
+
apiBaseUrl: 'https://api.github.com',
|
|
12
|
+
token: '',
|
|
13
|
+
tokenEnv: TOKEN_REF,
|
|
14
|
+
tokenFile: '',
|
|
15
|
+
hostId: '',
|
|
16
|
+
stateFile: '',
|
|
17
|
+
maxInstallsPerRun: 20,
|
|
18
|
+
requestTimeoutMs: 30000,
|
|
19
|
+
allowInsecureHttp: false,
|
|
20
|
+
excludeBundles: [],
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const UI_FIELDS = ['gistName']
|
|
24
|
+
const LEGACY_FIELDS = [
|
|
25
|
+
'repo',
|
|
26
|
+
'branch',
|
|
27
|
+
'path',
|
|
28
|
+
'autoSyncOnStart',
|
|
29
|
+
'startupDelayMs',
|
|
30
|
+
'syncIntervalMinutes',
|
|
31
|
+
'syncOnPluginChange',
|
|
32
|
+
'changeDebounceMs',
|
|
33
|
+
'autoInstall',
|
|
34
|
+
'dryRun',
|
|
35
|
+
'includeDisabledBundles',
|
|
36
|
+
'enableExistingBundles',
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
export class ConfigError extends Error {
|
|
40
|
+
constructor(message, code = 'invalid-config') {
|
|
41
|
+
super(message)
|
|
42
|
+
this.name = 'ConfigError'
|
|
43
|
+
this.code = code
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isRecord(value) {
|
|
48
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function asBoolean(value, fallback) {
|
|
52
|
+
return typeof value === 'boolean' ? value : fallback
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function asInteger(value, fallback, minimum, maximum) {
|
|
56
|
+
const number = typeof value === 'number' ? value : Number(value)
|
|
57
|
+
if (!Number.isFinite(number)) return fallback
|
|
58
|
+
return Math.min(maximum, Math.max(minimum, Math.trunc(number)))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function asString(value, fallback = '') {
|
|
62
|
+
return typeof value === 'string' ? value.trim() : fallback
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function defaultHostId() {
|
|
66
|
+
const raw = hostname().trim().toLowerCase()
|
|
67
|
+
const normalized = raw.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100)
|
|
68
|
+
return normalized === '' ? 'dsh-host' : normalized
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeHostId(value) {
|
|
72
|
+
const raw = asString(value)
|
|
73
|
+
if (raw === '') return defaultHostId()
|
|
74
|
+
const normalized = raw.replace(/[\u0000-\u001f\u007f]+/g, '-').replace(/\s+/g, '-').slice(0, 100)
|
|
75
|
+
if (['__proto__', 'constructor', 'prototype'].includes(normalized)) return defaultHostId()
|
|
76
|
+
return normalized
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function normalizeGistName(value) {
|
|
80
|
+
if (typeof value !== 'string') return DEFAULT_GIST_NAME
|
|
81
|
+
const name = value.trim()
|
|
82
|
+
if (name === '' || name.length > 100 || /[\u0000-\u001f\u007f]/.test(name)) return DEFAULT_GIST_NAME
|
|
83
|
+
return name
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function normalizeGistId(value) {
|
|
87
|
+
if (typeof value !== 'string') return ''
|
|
88
|
+
let text = value.trim()
|
|
89
|
+
if (text === '') return ''
|
|
90
|
+
try {
|
|
91
|
+
const url = new URL(text)
|
|
92
|
+
const parts = url.pathname.split('/').filter((part) => part !== '')
|
|
93
|
+
text = parts[parts.length - 1] ?? ''
|
|
94
|
+
} catch {
|
|
95
|
+
// A bare gist id is expected.
|
|
96
|
+
}
|
|
97
|
+
text = text.replace(/[?#].*$/, '')
|
|
98
|
+
return /^[A-Za-z0-9]{8,64}$/.test(text) ? text : ''
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function normalizeGistFileName(value) {
|
|
102
|
+
if (typeof value !== 'string') return DEFAULT_CONFIG.fileName
|
|
103
|
+
const name = value.trim()
|
|
104
|
+
if (name === '' || name.length > 200) return DEFAULT_CONFIG.fileName
|
|
105
|
+
if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') return DEFAULT_CONFIG.fileName
|
|
106
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) return DEFAULT_CONFIG.fileName
|
|
107
|
+
return name
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function gistUrl(gistId) {
|
|
111
|
+
return typeof gistId === 'string' && gistId !== '' ? `https://gist.github.com/${gistId}` : null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isLoopbackHost(host) {
|
|
115
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeApiBaseUrl(value, allowInsecureHttp) {
|
|
119
|
+
const fallback = DEFAULT_CONFIG.apiBaseUrl
|
|
120
|
+
if (typeof value !== 'string' || value.trim() === '') return { value: fallback, error: null }
|
|
121
|
+
let url
|
|
122
|
+
try {
|
|
123
|
+
url = new URL(value.trim())
|
|
124
|
+
} catch {
|
|
125
|
+
return { value: fallback, error: 'config.apiBaseUrl is not a valid URL; using api.github.com' }
|
|
126
|
+
}
|
|
127
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
128
|
+
return { value: fallback, error: 'config.apiBaseUrl must use http or https' }
|
|
129
|
+
}
|
|
130
|
+
if (url.protocol === 'http:' && !allowInsecureHttp && !isLoopbackHost(url.hostname)) {
|
|
131
|
+
return {
|
|
132
|
+
value: fallback,
|
|
133
|
+
error: 'config.apiBaseUrl uses plain HTTP; set allowInsecureHttp only for a trusted local mock',
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
url.search = ''
|
|
137
|
+
url.hash = ''
|
|
138
|
+
return { value: url.toString().replace(/\/+$/, ''), error: null }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeExclude(value) {
|
|
142
|
+
if (!Array.isArray(value)) return []
|
|
143
|
+
const seen = new Set()
|
|
144
|
+
for (const entry of value) {
|
|
145
|
+
if (typeof entry !== 'string') continue
|
|
146
|
+
const name = entry.trim()
|
|
147
|
+
if (name === '') continue
|
|
148
|
+
seen.add(name)
|
|
149
|
+
}
|
|
150
|
+
return [...seen].slice(0, 500)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function normalizeConfig(raw) {
|
|
154
|
+
const input = isRecord(raw) ? raw : {}
|
|
155
|
+
const allowInsecureHttp = asBoolean(input.allowInsecureHttp, DEFAULT_CONFIG.allowInsecureHttp)
|
|
156
|
+
const apiBase = normalizeApiBaseUrl(input.apiBaseUrl, allowInsecureHttp)
|
|
157
|
+
const gistNameText = asString(input.gistName) || asString(input.description) || DEFAULT_GIST_NAME
|
|
158
|
+
const gistName = normalizeGistName(gistNameText)
|
|
159
|
+
const config = {
|
|
160
|
+
gistName,
|
|
161
|
+
fileName: normalizeGistFileName(input.fileName),
|
|
162
|
+
description: asString(input.description, DEFAULT_CONFIG.description).slice(0, 200)
|
|
163
|
+
|| DEFAULT_CONFIG.description,
|
|
164
|
+
apiBaseUrl: apiBase.value,
|
|
165
|
+
token: asString(input.token),
|
|
166
|
+
tokenEnv: asString(input.tokenEnv, DEFAULT_CONFIG.tokenEnv),
|
|
167
|
+
tokenFile: expandHome(asString(input.tokenFile)),
|
|
168
|
+
hostId: normalizeHostId(input.hostId),
|
|
169
|
+
stateFile: expandHome(asString(input.stateFile)),
|
|
170
|
+
profile: process.env.DSH_PROFILE || '',
|
|
171
|
+
profileDir: process.env.DSH_PROFILE_DIR || '',
|
|
172
|
+
dshHome: process.env.DSH_HOME || '',
|
|
173
|
+
packHome: asString(input.packHome),
|
|
174
|
+
maxInstallsPerRun: asInteger(input.maxInstallsPerRun, DEFAULT_CONFIG.maxInstallsPerRun, 1, 50),
|
|
175
|
+
requestTimeoutMs: asInteger(input.requestTimeoutMs, DEFAULT_CONFIG.requestTimeoutMs, 1_000, 120_000),
|
|
176
|
+
allowInsecureHttp,
|
|
177
|
+
excludeBundles: normalizeExclude(input.excludeBundles),
|
|
178
|
+
configError: undefined,
|
|
179
|
+
}
|
|
180
|
+
if (apiBase.error !== null) {
|
|
181
|
+
config.configError = apiBase.error
|
|
182
|
+
}
|
|
183
|
+
return config
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function publicConfig(config) {
|
|
187
|
+
return {
|
|
188
|
+
gistName: config.gistName,
|
|
189
|
+
defaultGistName: DEFAULT_GIST_NAME,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function serializeConfig(config, currentRaw = {}) {
|
|
194
|
+
const raw = isRecord(currentRaw) ? currentRaw : {}
|
|
195
|
+
const rawHostId = asString(raw.hostId)
|
|
196
|
+
return {
|
|
197
|
+
gistName: config.gistName,
|
|
198
|
+
...(config.fileName === DEFAULT_CONFIG.fileName ? {} : { fileName: config.fileName }),
|
|
199
|
+
...(config.description === DEFAULT_CONFIG.description ? {} : { description: config.description }),
|
|
200
|
+
...(rawHostId === '' && config.hostId === defaultHostId() ? {} : { hostId: config.hostId }),
|
|
201
|
+
...(config.tokenEnv === TOKEN_REF ? {} : { tokenEnv: config.tokenEnv }),
|
|
202
|
+
...(config.tokenFile === '' ? {} : { tokenFile: config.tokenFile }),
|
|
203
|
+
...(config.maxInstallsPerRun === DEFAULT_CONFIG.maxInstallsPerRun
|
|
204
|
+
? {}
|
|
205
|
+
: { maxInstallsPerRun: config.maxInstallsPerRun }),
|
|
206
|
+
...(config.apiBaseUrl === DEFAULT_CONFIG.apiBaseUrl ? {} : { apiBaseUrl: config.apiBaseUrl }),
|
|
207
|
+
...(config.requestTimeoutMs === DEFAULT_CONFIG.requestTimeoutMs
|
|
208
|
+
? {}
|
|
209
|
+
: { requestTimeoutMs: config.requestTimeoutMs }),
|
|
210
|
+
...(config.allowInsecureHttp ? { allowInsecureHttp: true } : {}),
|
|
211
|
+
...(config.excludeBundles.length === 0 ? {} : { excludeBundles: config.excludeBundles }),
|
|
212
|
+
...(config.stateFile === '' ? {} : { stateFile: config.stateFile }),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function findConfigEntry(editor) {
|
|
217
|
+
const entries = editor.configuration()
|
|
218
|
+
if (!Array.isArray(entries)) return null
|
|
219
|
+
return entries.find((item) => item?.entry?.options?.id === 'dsh-plugin-syncup') ?? null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function readConfigSnapshot(ctx, fallbackConfig) {
|
|
223
|
+
const editor = ctx.get('configEditor')
|
|
224
|
+
if (editor === undefined || typeof editor.configuration !== 'function') {
|
|
225
|
+
return {
|
|
226
|
+
editable: false,
|
|
227
|
+
reason: 'config-editor-unavailable',
|
|
228
|
+
values: publicConfig(fallbackConfig),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
let item
|
|
232
|
+
try {
|
|
233
|
+
item = findConfigEntry(editor)
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return {
|
|
236
|
+
editable: false,
|
|
237
|
+
reason: 'config-read-failed',
|
|
238
|
+
message: error instanceof Error ? error.message : String(error),
|
|
239
|
+
values: publicConfig(fallbackConfig),
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (item === null) {
|
|
243
|
+
return {
|
|
244
|
+
editable: false,
|
|
245
|
+
reason: 'entry-not-found',
|
|
246
|
+
values: publicConfig(fallbackConfig),
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const effective = normalizeConfig(item.entry?.options?.config ?? item.inherited ?? {})
|
|
250
|
+
const inherited = normalizeConfig(item.inherited ?? {})
|
|
251
|
+
return {
|
|
252
|
+
editable: true,
|
|
253
|
+
entryId: item.entry.options.id,
|
|
254
|
+
values: publicConfig(effective),
|
|
255
|
+
inherited: publicConfig(inherited),
|
|
256
|
+
overridden: isRecord(item.override) && Object.keys(item.override).length > 0,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function sanitizeConfigPatch(values) {
|
|
261
|
+
const input = isRecord(values) ? values : {}
|
|
262
|
+
const patch = {}
|
|
263
|
+
for (const key of UI_FIELDS) {
|
|
264
|
+
if (!Object.hasOwn(input, key)) continue
|
|
265
|
+
patch[key] = asString(input[key])
|
|
266
|
+
}
|
|
267
|
+
return patch
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function describeCredential(ctx) {
|
|
271
|
+
const credentials = ctx.get('credentials')
|
|
272
|
+
if (credentials === undefined || typeof credentials.describe !== 'function') {
|
|
273
|
+
return { available: false, configured: false, writable: false, source: null }
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const info = await credentials.describe(TOKEN_REF)
|
|
277
|
+
return {
|
|
278
|
+
available: true,
|
|
279
|
+
configured: info.configured === true,
|
|
280
|
+
writable: info.writable === true,
|
|
281
|
+
source: typeof info.source === 'string' ? info.source : null,
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
return {
|
|
285
|
+
available: false,
|
|
286
|
+
configured: false,
|
|
287
|
+
writable: false,
|
|
288
|
+
source: null,
|
|
289
|
+
message: error instanceof Error ? error.message : String(error),
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function setCredential(ctx, token) {
|
|
295
|
+
const credentials = ctx.get('credentials')
|
|
296
|
+
if (credentials === undefined || typeof credentials.set !== 'function') {
|
|
297
|
+
throw new ConfigError('Credential storage is unavailable on this Host', 'credentials-unavailable')
|
|
298
|
+
}
|
|
299
|
+
const value = asString(token)
|
|
300
|
+
if (value === '') throw new ConfigError('Token must not be empty', 'invalid-token')
|
|
301
|
+
await credentials.set(TOKEN_REF, value)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function clearCredential(ctx) {
|
|
305
|
+
const credentials = ctx.get('credentials')
|
|
306
|
+
if (credentials === undefined || typeof credentials.unset !== 'function') {
|
|
307
|
+
throw new ConfigError('Credential storage is unavailable on this Host', 'credentials-unavailable')
|
|
308
|
+
}
|
|
309
|
+
await credentials.unset(TOKEN_REF)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function writeConfigValues(ctx, values, fallbackConfig) {
|
|
313
|
+
const editor = ctx.get('configEditor')
|
|
314
|
+
if (editor === undefined || typeof editor.edit !== 'function') {
|
|
315
|
+
throw new ConfigError('Configuration editing is unavailable on this Host', 'config-editor-unavailable')
|
|
316
|
+
}
|
|
317
|
+
const item = findConfigEntry(editor)
|
|
318
|
+
if (item === null) throw new ConfigError('The plugin row cannot be located in the active profile', 'entry-not-found')
|
|
319
|
+
const currentRaw = isRecord(item.entry?.options?.config) ? item.entry.options.config : {}
|
|
320
|
+
const patch = sanitizeConfigPatch(values)
|
|
321
|
+
const nextRaw = { ...currentRaw, ...patch }
|
|
322
|
+
for (const key of LEGACY_FIELDS) Reflect.deleteProperty(nextRaw, key)
|
|
323
|
+
const normalized = normalizeConfig(nextRaw)
|
|
324
|
+
if (normalized.configError !== undefined) {
|
|
325
|
+
throw new ConfigError(normalized.configError, 'invalid-config')
|
|
326
|
+
}
|
|
327
|
+
const serialized = serializeConfig(normalized, currentRaw)
|
|
328
|
+
await editor.edit(item.entry, () => serialized)
|
|
329
|
+
return publicConfig(normalized)
|
|
330
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const MESSAGES = {
|
|
2
|
+
'already-installed': 'The bundle is already in this profile. Updating an existing bundle needs a remove-then-install step.',
|
|
3
|
+
'ambiguous-install': 'The spec matches more than one package. Name the package explicitly.',
|
|
4
|
+
'bundle-in-use': 'The bundle is currently in use; stop it or restart DSH before changing it.',
|
|
5
|
+
'incompatible-version': 'The package requires a different DSH version than this Host runs.',
|
|
6
|
+
'invalid-spec': 'The install spec is not valid.',
|
|
7
|
+
'management-required': 'This bundle is managed by the DSH installation and cannot be changed from a profile.',
|
|
8
|
+
'not-bundle': 'The package does not provide a bundle patch, so it cannot be activated.',
|
|
9
|
+
'not-removable': 'This bundle cannot be removed.',
|
|
10
|
+
'stale-approval': 'The recorded build approval is stale; approve the build again.',
|
|
11
|
+
'stop-profile': 'The profile is stopping, so the change was refused.',
|
|
12
|
+
'unknown-plugin': 'The bundle is not present in this profile.',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const HINTS = {
|
|
16
|
+
'already-installed': 'Enable the remove-then-install upgrade path, or update the bundle from the Plugins page.',
|
|
17
|
+
'incompatible-version': 'Update DSH, or keep the local version.',
|
|
18
|
+
'management-required': 'Change it from the DSH installation instead of this profile.',
|
|
19
|
+
'not-bundle': 'Install it as a dependency instead; it has no bundle patch to activate.',
|
|
20
|
+
'not-removable': 'Keep the local copy.',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function describeManagementCode(code) {
|
|
24
|
+
if (typeof code !== 'string' || code === '') return null
|
|
25
|
+
const message = MESSAGES[code]
|
|
26
|
+
if (message === undefined) return null
|
|
27
|
+
const hint = HINTS[code]
|
|
28
|
+
return hint === undefined ? message : `${message} ${hint}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Turn a plugin-manager failure into one readable line.
|
|
33
|
+
*
|
|
34
|
+
* The manager reports `{ changed: false, application: 'failed', error: { code } }`
|
|
35
|
+
* with no message for most refusals, so a bare `String(error)` degrades to
|
|
36
|
+
* "[object Object]". Every shape is handled here: a plain string, a ManagementFailure
|
|
37
|
+
* instance, a coded record, or anything else.
|
|
38
|
+
*/
|
|
39
|
+
export function describeChangeError(error) {
|
|
40
|
+
if (error === null || error === undefined) return null
|
|
41
|
+
if (typeof error === 'string') return error.trim() === '' ? null : error.trim()
|
|
42
|
+
if (typeof error !== 'object') return String(error)
|
|
43
|
+
|
|
44
|
+
const code = managementCodeOf(error)
|
|
45
|
+
for (const key of ['diagnostic', 'message', 'reason', 'detail', 'description']) {
|
|
46
|
+
const value = error[key]
|
|
47
|
+
if (typeof value !== 'string' || value.trim() === '' || value === '[object Object]') continue
|
|
48
|
+
// ManagementFailure sets message to the code itself, so a localized
|
|
49
|
+
// description reads far better than the bare identifier.
|
|
50
|
+
if (code !== null && value.trim() === code) continue
|
|
51
|
+
return value.trim()
|
|
52
|
+
}
|
|
53
|
+
const described = describeManagementCode(code)
|
|
54
|
+
if (described !== null) {
|
|
55
|
+
const incompatible = Array.isArray(error.incompatible) ? error.incompatible : []
|
|
56
|
+
if (incompatible.length === 0) return described
|
|
57
|
+
const names = incompatible
|
|
58
|
+
.map((item) => (typeof item === 'string' ? item : item?.name))
|
|
59
|
+
.filter((item) => typeof item === 'string' && item !== '')
|
|
60
|
+
return names.length === 0 ? described : `${described} Incompatible: ${names.join(', ')}.`
|
|
61
|
+
}
|
|
62
|
+
if (code !== null) return `The plugin manager refused the change (${code}).`
|
|
63
|
+
const keys = Object.keys(error)
|
|
64
|
+
if (keys.length > 0) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.stringify(error).slice(0, 500)
|
|
67
|
+
} catch {
|
|
68
|
+
return `The plugin manager failed with fields: ${keys.join(', ')}`
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return 'The plugin manager failed without a reason.'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function managementCodeOf(error) {
|
|
75
|
+
if (error === null || error === undefined) return null
|
|
76
|
+
if (typeof error === 'string') return error
|
|
77
|
+
if (typeof error !== 'object') return null
|
|
78
|
+
if (typeof error.code === 'string' && error.code !== '') return error.code
|
|
79
|
+
if (typeof error.reason === 'string' && error.reason !== '') return error.reason
|
|
80
|
+
return null
|
|
81
|
+
}
|