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/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
+ }