dsh-plugin-syncup 0.0.0-stage → 4.0.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 +1507 -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/manifest.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { compareVersions, formatIso, isLocalSpec, isPortableSpec, isRecord } from './spec.js'
|
|
2
|
+
|
|
3
|
+
export const MANIFEST_SCHEMA_VERSION = 1
|
|
4
|
+
export const MANIFEST_GENERATOR = 'dsh-plugin-syncup'
|
|
5
|
+
|
|
6
|
+
const MAX_BUNDLES_PER_HOST = 2000
|
|
7
|
+
|
|
8
|
+
export function emptyManifest(now = new Date().toISOString()) {
|
|
9
|
+
return {
|
|
10
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
11
|
+
generatedBy: MANIFEST_GENERATOR,
|
|
12
|
+
updatedAt: now,
|
|
13
|
+
hosts: {},
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizePackageName(value) {
|
|
18
|
+
if (typeof value !== 'string') return ''
|
|
19
|
+
const name = value.trim()
|
|
20
|
+
if (name === '' || name.length > 214) return ''
|
|
21
|
+
if (!/^(?:@[^/\s]+\/)?[^/\s]+$/.test(name)) return ''
|
|
22
|
+
return name
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeSpec(value) {
|
|
26
|
+
if (typeof value !== 'string') return ''
|
|
27
|
+
const spec = value.trim()
|
|
28
|
+
return isPortableSpec(spec) ? spec : ''
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeBundle(value) {
|
|
32
|
+
if (!isRecord(value)) return null
|
|
33
|
+
const name = normalizePackageName(value.name)
|
|
34
|
+
if (name === '') return null
|
|
35
|
+
const version = typeof value.version === 'string' ? value.version.trim() : ''
|
|
36
|
+
const spec = normalizeSpec(value.spec) || (version === '' ? '' : normalizeSpec(`${name}@${version}`))
|
|
37
|
+
if (spec === '') return null
|
|
38
|
+
return {
|
|
39
|
+
name,
|
|
40
|
+
spec,
|
|
41
|
+
version,
|
|
42
|
+
enabled: value.enabled !== false,
|
|
43
|
+
...(typeof value.source === 'string' && value.source.trim() !== ''
|
|
44
|
+
? { source: value.source.trim() }
|
|
45
|
+
: {}),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeHostEntry(value, hostId) {
|
|
50
|
+
if (!isRecord(value)) return null
|
|
51
|
+
if (['__proto__', 'constructor', 'prototype'].includes(hostId)) return null
|
|
52
|
+
const bundles = []
|
|
53
|
+
const seen = new Set()
|
|
54
|
+
if (Array.isArray(value.bundles)) {
|
|
55
|
+
for (const rawBundle of value.bundles) {
|
|
56
|
+
const bundle = normalizeBundle(rawBundle)
|
|
57
|
+
if (bundle === null || seen.has(bundle.name)) continue
|
|
58
|
+
bundles.push(bundle)
|
|
59
|
+
seen.add(bundle.name)
|
|
60
|
+
if (bundles.length >= MAX_BUNDLES_PER_HOST) break
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
bundles.sort((left, right) => left.name.localeCompare(right.name))
|
|
64
|
+
return {
|
|
65
|
+
hostId: typeof value.hostId === 'string' && value.hostId.trim() !== '' ? value.hostId.trim() : hostId,
|
|
66
|
+
updatedAt: formatIso(value.updatedAt),
|
|
67
|
+
...(typeof value.dshVersion === 'string' && value.dshVersion.trim() !== ''
|
|
68
|
+
? { dshVersion: value.dshVersion.trim() }
|
|
69
|
+
: {}),
|
|
70
|
+
...(typeof value.profile === 'string' && value.profile.trim() !== ''
|
|
71
|
+
? { profile: value.profile.trim() }
|
|
72
|
+
: {}),
|
|
73
|
+
...(typeof value.platform === 'string' && value.platform.trim() !== ''
|
|
74
|
+
? { platform: value.platform.trim() }
|
|
75
|
+
: {}),
|
|
76
|
+
bundles,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function normalizeManifest(value, { source = 'manifest', requireShape = false } = {}) {
|
|
81
|
+
if (value === null || value === undefined) return emptyManifest()
|
|
82
|
+
if (!isRecord(value)) throw new Error(`${source} must be a JSON object`)
|
|
83
|
+
if (
|
|
84
|
+
requireShape
|
|
85
|
+
&& value.schemaVersion === undefined
|
|
86
|
+
&& value.hosts === undefined
|
|
87
|
+
&& value.generatedBy === undefined
|
|
88
|
+
) {
|
|
89
|
+
throw new Error(`${source} does not look like a plugin-sync manifest; refusing to overwrite it`)
|
|
90
|
+
}
|
|
91
|
+
const schemaVersion = value.schemaVersion === undefined ? MANIFEST_SCHEMA_VERSION : value.schemaVersion
|
|
92
|
+
if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
|
|
93
|
+
throw new Error(`${source} has an invalid schemaVersion`)
|
|
94
|
+
}
|
|
95
|
+
if (schemaVersion > MANIFEST_SCHEMA_VERSION) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`${source} uses schemaVersion ${schemaVersion}; this plugin supports up to ${MANIFEST_SCHEMA_VERSION}`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
const hosts = {}
|
|
101
|
+
if (value.hosts !== undefined) {
|
|
102
|
+
if (!isRecord(value.hosts)) throw new Error(`${source}.hosts must be a JSON object`)
|
|
103
|
+
for (const [hostId, rawHost] of Object.entries(value.hosts)) {
|
|
104
|
+
const host = normalizeHostEntry(rawHost, hostId)
|
|
105
|
+
if (host !== null) hosts[hostId] = host
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
...value,
|
|
110
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
111
|
+
generatedBy: typeof value.generatedBy === 'string' ? value.generatedBy : MANIFEST_GENERATOR,
|
|
112
|
+
updatedAt: formatIso(value.updatedAt),
|
|
113
|
+
hosts,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function pickInstallSpec(bundle) {
|
|
118
|
+
if (!isRecord(bundle)) return ''
|
|
119
|
+
const target = isRecord(bundle.installTarget) ? bundle.installTarget.spec : undefined
|
|
120
|
+
if (typeof target === 'string' && isPortableSpec(target)) return target.trim()
|
|
121
|
+
const source = typeof bundle.source === 'string' ? bundle.source.trim() : ''
|
|
122
|
+
if (source !== '') {
|
|
123
|
+
if (isLocalSpec(source)) return ''
|
|
124
|
+
if (/^(?:github:|git\+|https?:|npm:)/i.test(source) || /^git@[^:\s]+:[^ \t]+$/.test(source)) return source
|
|
125
|
+
if (typeof bundle.version === 'string' && bundle.version.trim() !== '') {
|
|
126
|
+
return `${bundle.name}@${bundle.version.trim()}`
|
|
127
|
+
}
|
|
128
|
+
if (isPortableSpec(source)) return source
|
|
129
|
+
}
|
|
130
|
+
if (typeof bundle.version === 'string' && bundle.version.trim() !== '') {
|
|
131
|
+
return `${bundle.name}@${bundle.version.trim()}`
|
|
132
|
+
}
|
|
133
|
+
return ''
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function localHostEntry(bundles, {
|
|
137
|
+
hostId,
|
|
138
|
+
profile = '',
|
|
139
|
+
platform = process.platform,
|
|
140
|
+
now = new Date().toISOString(),
|
|
141
|
+
exclude = [],
|
|
142
|
+
} = {}) {
|
|
143
|
+
const excluded = new Set(exclude)
|
|
144
|
+
const entries = []
|
|
145
|
+
const seen = new Set()
|
|
146
|
+
for (const bundle of Array.isArray(bundles) ? bundles : []) {
|
|
147
|
+
if (!isRecord(bundle) || typeof bundle.name !== 'string') continue
|
|
148
|
+
if (bundle.availability !== 'profile') continue
|
|
149
|
+
if (bundle.installed !== true) continue
|
|
150
|
+
if (bundle.removable === false) continue
|
|
151
|
+
if (excluded.has(bundle.name) || seen.has(bundle.name)) continue
|
|
152
|
+
const spec = pickInstallSpec(bundle)
|
|
153
|
+
if (!isPortableSpec(spec)) continue
|
|
154
|
+
seen.add(bundle.name)
|
|
155
|
+
entries.push({
|
|
156
|
+
name: bundle.name,
|
|
157
|
+
spec,
|
|
158
|
+
version: typeof bundle.version === 'string' ? bundle.version : '',
|
|
159
|
+
enabled: bundle.enabled !== false,
|
|
160
|
+
...(typeof bundle.source === 'string' && bundle.source.trim() !== ''
|
|
161
|
+
? { source: bundle.source.trim() }
|
|
162
|
+
: {}),
|
|
163
|
+
})
|
|
164
|
+
if (entries.length >= MAX_BUNDLES_PER_HOST) break
|
|
165
|
+
}
|
|
166
|
+
entries.sort((left, right) => left.name.localeCompare(right.name))
|
|
167
|
+
const dshVersion = (Array.isArray(bundles) ? bundles : []).find(
|
|
168
|
+
(bundle) => isRecord(bundle) && bundle.name === '@deepseek-ai/dsh-base' && typeof bundle.version === 'string',
|
|
169
|
+
)?.version
|
|
170
|
+
return {
|
|
171
|
+
hostId,
|
|
172
|
+
updatedAt: formatIso(now),
|
|
173
|
+
...(typeof dshVersion === 'string' && dshVersion !== '' ? { dshVersion } : {}),
|
|
174
|
+
...(profile !== '' ? { profile } : {}),
|
|
175
|
+
platform,
|
|
176
|
+
bundles: entries,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function mergeHost(manifest, hostId, hostEntry, now = new Date().toISOString()) {
|
|
181
|
+
return {
|
|
182
|
+
...manifest,
|
|
183
|
+
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
|
184
|
+
generatedBy: MANIFEST_GENERATOR,
|
|
185
|
+
updatedAt: formatIso(now),
|
|
186
|
+
lastWriter: hostId,
|
|
187
|
+
hosts: {
|
|
188
|
+
...manifest.hosts,
|
|
189
|
+
[hostId]: hostEntry,
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function candidateOrder(left, right) {
|
|
195
|
+
const versionCompared = compareVersions(left.version, right.version)
|
|
196
|
+
if (versionCompared !== 0) return -versionCompared
|
|
197
|
+
return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function desiredBundles(manifest, {
|
|
201
|
+
includeDisabled = false,
|
|
202
|
+
exclude = [],
|
|
203
|
+
excludeHosts = [],
|
|
204
|
+
} = {}) {
|
|
205
|
+
const excluded = new Set(exclude)
|
|
206
|
+
const ignoredHosts = new Set(excludeHosts)
|
|
207
|
+
const groups = new Map()
|
|
208
|
+
for (const [hostId, host] of Object.entries(manifest.hosts ?? {})) {
|
|
209
|
+
if (ignoredHosts.has(hostId)) continue
|
|
210
|
+
for (const bundle of host.bundles ?? []) {
|
|
211
|
+
if (excluded.has(bundle.name)) continue
|
|
212
|
+
let group = groups.get(bundle.name)
|
|
213
|
+
if (group === undefined) {
|
|
214
|
+
group = []
|
|
215
|
+
groups.set(bundle.name, group)
|
|
216
|
+
}
|
|
217
|
+
group.push({
|
|
218
|
+
...bundle,
|
|
219
|
+
hostId,
|
|
220
|
+
hostUpdatedAt: host.updatedAt,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const desired = []
|
|
225
|
+
for (const [name, candidates] of groups) {
|
|
226
|
+
const enabled = candidates.some((candidate) => candidate.enabled !== false)
|
|
227
|
+
if (!enabled && !includeDisabled) continue
|
|
228
|
+
candidates.sort(candidateOrder)
|
|
229
|
+
const best = candidates[0]
|
|
230
|
+
const variants = candidates.map((candidate) => ({
|
|
231
|
+
hostId: candidate.hostId,
|
|
232
|
+
spec: candidate.spec,
|
|
233
|
+
version: candidate.version,
|
|
234
|
+
enabled: candidate.enabled !== false,
|
|
235
|
+
updatedAt: candidate.hostUpdatedAt,
|
|
236
|
+
}))
|
|
237
|
+
const distinctVariants = new Set(variants.map((variant) => `${variant.spec}\u0000${variant.version}`))
|
|
238
|
+
desired.push({
|
|
239
|
+
name,
|
|
240
|
+
spec: best.spec,
|
|
241
|
+
version: best.version,
|
|
242
|
+
enabled,
|
|
243
|
+
hostId: best.hostId,
|
|
244
|
+
hostUpdatedAt: best.hostUpdatedAt,
|
|
245
|
+
hosts: [...new Set(candidates.map((candidate) => candidate.hostId))].sort(),
|
|
246
|
+
variants,
|
|
247
|
+
hostConflict: distinctVariants.size > 1,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
desired.sort((left, right) => left.name.localeCompare(right.name))
|
|
251
|
+
return desired
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function summarizeManifest(manifest) {
|
|
255
|
+
const hosts = Object.values(manifest.hosts ?? {}).map((host) => ({
|
|
256
|
+
hostId: host.hostId,
|
|
257
|
+
updatedAt: host.updatedAt,
|
|
258
|
+
...(host.dshVersion === undefined ? {} : { dshVersion: host.dshVersion }),
|
|
259
|
+
...(host.profile === undefined ? {} : { profile: host.profile }),
|
|
260
|
+
bundles: host.bundles.length,
|
|
261
|
+
enabled: host.bundles.filter((bundle) => bundle.enabled).length,
|
|
262
|
+
}))
|
|
263
|
+
hosts.sort((left, right) => left.hostId.localeCompare(right.hostId))
|
|
264
|
+
return {
|
|
265
|
+
schemaVersion: manifest.schemaVersion,
|
|
266
|
+
generatedBy: manifest.generatedBy,
|
|
267
|
+
updatedAt: manifest.updatedAt,
|
|
268
|
+
hostCount: hosts.length,
|
|
269
|
+
bundleCount: hosts.reduce((total, host) => total + host.bundles, 0),
|
|
270
|
+
hosts,
|
|
271
|
+
}
|
|
272
|
+
}
|
package/lib/manual.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
2
|
+
import { expandHome } from './spec.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where the active profile lives on disk.
|
|
6
|
+
*
|
|
7
|
+
* DSH does not hand a plugin its profile directory, so this reconstructs the
|
|
8
|
+
* conventional `<DSH_HOME>/profiles/<profile>` layout and only reports a path it
|
|
9
|
+
* can actually build. `DSH_PROFILE_DIR` wins when the host sets it.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveProfileDir(config = {}) {
|
|
12
|
+
const explicit = typeof config.profileDir === 'string' ? config.profileDir.trim() : ''
|
|
13
|
+
if (explicit !== '') return expandHome(explicit)
|
|
14
|
+
const home = typeof config.dshHome === 'string' && config.dshHome.trim() !== ''
|
|
15
|
+
? expandHome(config.dshHome.trim())
|
|
16
|
+
: ''
|
|
17
|
+
const profile = typeof config.profile === 'string' ? config.profile.trim() : ''
|
|
18
|
+
if (home === '' || profile === '') return ''
|
|
19
|
+
return join(home, 'profiles', profile)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function quote(value) {
|
|
23
|
+
return `"${value}"`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Terminal instructions for updating a bundle that HMR refuses to unload.
|
|
28
|
+
*
|
|
29
|
+
* The command is deliberately `pnpm add` rather than a remove-then-add: with DSH
|
|
30
|
+
* closed there is no live HMR to fight, and pnpm rewrites the dependency in one
|
|
31
|
+
* step. `--config.registry` is omitted so the profile's own setting applies.
|
|
32
|
+
*/
|
|
33
|
+
export function manualUpdateInstructions({ name, spec, profileDir, packHome }) {
|
|
34
|
+
const target = typeof spec === 'string' && spec.trim() !== '' ? spec.trim() : name
|
|
35
|
+
const safeName = typeof name === 'string' && name !== '' ? name : target
|
|
36
|
+
const steps = []
|
|
37
|
+
const dir = typeof profileDir === 'string' ? profileDir : ''
|
|
38
|
+
const pnpm = typeof packHome === 'string' && packHome !== ''
|
|
39
|
+
? join(packHome, 'dependencies', 'pnpm', 'bin', 'pnpm.cjs')
|
|
40
|
+
: ''
|
|
41
|
+
const node = typeof packHome === 'string' && packHome !== ''
|
|
42
|
+
? join(packHome, 'dependencies', 'node', 'bin', 'node.exe')
|
|
43
|
+
: ''
|
|
44
|
+
|
|
45
|
+
steps.push({
|
|
46
|
+
title: 'Quit DSH completely',
|
|
47
|
+
detail: 'HMR keeps the old module loaded while DSH runs, so the update needs a closed host. Close the window and make sure no DSH process is left.',
|
|
48
|
+
})
|
|
49
|
+
steps.push({
|
|
50
|
+
title: 'Open a terminal in the profile directory',
|
|
51
|
+
detail: dir === ''
|
|
52
|
+
? 'Open the folder that holds this profile\'s package.json.'
|
|
53
|
+
: `cd ${quote(dir)}`,
|
|
54
|
+
command: dir === '' ? '' : `cd ${quote(dir)}`,
|
|
55
|
+
})
|
|
56
|
+
steps.push({
|
|
57
|
+
title: 'Update the package',
|
|
58
|
+
detail: 'Installs the newest release into the profile and rewrites its dependency entry.',
|
|
59
|
+
command: pnpm !== '' && node !== ''
|
|
60
|
+
? `${quote(node)} ${quote(pnpm)} add ${target}`
|
|
61
|
+
: `pnpm add ${target}`,
|
|
62
|
+
})
|
|
63
|
+
steps.push({
|
|
64
|
+
title: 'Start DSH again',
|
|
65
|
+
detail: `The new version loads on start. "${safeName}" stays installed and enabled throughout.`,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
name: safeName,
|
|
70
|
+
spec: target,
|
|
71
|
+
profileDir: dir,
|
|
72
|
+
steps,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Relative-safe display path so the page never shows a bare absolute path twice. */
|
|
77
|
+
export function displayPath(value, base) {
|
|
78
|
+
if (typeof value !== 'string' || value === '') return ''
|
|
79
|
+
if (typeof base !== 'string' || base === '' || !isAbsolute(value)) return value
|
|
80
|
+
const resolved = resolve(value)
|
|
81
|
+
const resolvedBase = resolve(base)
|
|
82
|
+
return resolved.startsWith(`${resolvedBase}\\`) || resolved.startsWith(`${resolvedBase}/`)
|
|
83
|
+
? `.${resolved.slice(resolvedBase.length)}`
|
|
84
|
+
: resolved
|
|
85
|
+
}
|
package/lib/preview.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { desiredBundles, pickInstallSpec, summarizeManifest } from './manifest.js'
|
|
2
|
+
import { compareVersions } from './spec.js'
|
|
3
|
+
|
|
4
|
+
const KIND_ORDER = new Map([
|
|
5
|
+
['install', 0],
|
|
6
|
+
['upgrade', 1],
|
|
7
|
+
['downgrade', 2],
|
|
8
|
+
['spec-change', 3],
|
|
9
|
+
['enable', 4],
|
|
10
|
+
['remote-disabled', 5],
|
|
11
|
+
['current', 6],
|
|
12
|
+
['local-only', 7],
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
function localInstalled(bundle) {
|
|
16
|
+
return bundle !== null
|
|
17
|
+
&& typeof bundle === 'object'
|
|
18
|
+
&& (bundle.installed === true || bundle.availability === 'profile')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function localView(bundle) {
|
|
22
|
+
if (bundle === null) return null
|
|
23
|
+
return {
|
|
24
|
+
version: typeof bundle.version === 'string' ? bundle.version : '',
|
|
25
|
+
spec: pickInstallSpec(bundle),
|
|
26
|
+
enabled: bundle.enabled !== false,
|
|
27
|
+
source: typeof bundle.source === 'string' ? bundle.source : '',
|
|
28
|
+
removable: bundle.removable !== false,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function uniqueVariants(item) {
|
|
33
|
+
const seen = new Set()
|
|
34
|
+
const variants = []
|
|
35
|
+
for (const variant of item.variants ?? []) {
|
|
36
|
+
const key = `${variant.hostId}\u0000${variant.spec}\u0000${variant.version}`
|
|
37
|
+
if (seen.has(key)) continue
|
|
38
|
+
seen.add(key)
|
|
39
|
+
variants.push({
|
|
40
|
+
hostId: variant.hostId,
|
|
41
|
+
spec: variant.spec,
|
|
42
|
+
version: variant.version,
|
|
43
|
+
enabled: variant.enabled !== false,
|
|
44
|
+
})
|
|
45
|
+
if (variants.length >= 20) break
|
|
46
|
+
}
|
|
47
|
+
return variants
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hostConflict(item) {
|
|
51
|
+
return item.hostConflict === true
|
|
52
|
+
? {
|
|
53
|
+
type: 'host-disagreement',
|
|
54
|
+
variants: uniqueVariants(item),
|
|
55
|
+
}
|
|
56
|
+
: null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildPreview({
|
|
60
|
+
manifest,
|
|
61
|
+
localBundles = [],
|
|
62
|
+
hostId,
|
|
63
|
+
exclude = [],
|
|
64
|
+
now = new Date().toISOString(),
|
|
65
|
+
} = {}) {
|
|
66
|
+
const remote = desiredBundles(manifest, {
|
|
67
|
+
includeDisabled: true,
|
|
68
|
+
exclude,
|
|
69
|
+
excludeHosts: hostId === undefined ? [] : [hostId],
|
|
70
|
+
})
|
|
71
|
+
const local = new Map()
|
|
72
|
+
for (const bundle of localBundles) {
|
|
73
|
+
if (bundle !== null && typeof bundle === 'object' && typeof bundle.name === 'string' && localInstalled(bundle)) {
|
|
74
|
+
local.set(bundle.name, bundle)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const entries = []
|
|
78
|
+
const seen = new Set()
|
|
79
|
+
|
|
80
|
+
for (const item of remote) {
|
|
81
|
+
seen.add(item.name)
|
|
82
|
+
const current = local.get(item.name) ?? null
|
|
83
|
+
const currentView = localView(current)
|
|
84
|
+
const variants = uniqueVariants(item)
|
|
85
|
+
const sourceHosts = variants.map((variant) => variant.hostId)
|
|
86
|
+
const conflictFromHosts = hostConflict(item)
|
|
87
|
+
let kind
|
|
88
|
+
let choices
|
|
89
|
+
let defaultChoice
|
|
90
|
+
let conflict = null
|
|
91
|
+
|
|
92
|
+
if (current === null) {
|
|
93
|
+
kind = item.enabled ? 'install' : 'remote-disabled'
|
|
94
|
+
choices = item.enabled ? ['remote', 'skip'] : ['skip']
|
|
95
|
+
defaultChoice = item.enabled ? 'remote' : 'skip'
|
|
96
|
+
if (item.enabled && conflictFromHosts !== null) conflict = conflictFromHosts
|
|
97
|
+
} else if (!item.enabled) {
|
|
98
|
+
kind = 'remote-disabled'
|
|
99
|
+
choices = ['local']
|
|
100
|
+
defaultChoice = 'local'
|
|
101
|
+
} else {
|
|
102
|
+
const sameSpec = currentView.spec !== '' && currentView.spec === item.spec
|
|
103
|
+
const versionOrder = compareVersions(item.version, currentView.version)
|
|
104
|
+
if (sameSpec) {
|
|
105
|
+
if (currentView.enabled === false) {
|
|
106
|
+
kind = 'enable'
|
|
107
|
+
choices = ['remote', 'local']
|
|
108
|
+
defaultChoice = 'local'
|
|
109
|
+
conflict = { type: 'enable-state' }
|
|
110
|
+
} else {
|
|
111
|
+
kind = 'current'
|
|
112
|
+
choices = ['local']
|
|
113
|
+
defaultChoice = 'local'
|
|
114
|
+
}
|
|
115
|
+
} else if (item.hostConflict) {
|
|
116
|
+
kind = versionOrder > 0 ? 'upgrade' : versionOrder < 0 ? 'downgrade' : 'spec-change'
|
|
117
|
+
choices = ['remote', 'local', 'skip']
|
|
118
|
+
defaultChoice = versionOrder > 0 ? 'remote' : 'local'
|
|
119
|
+
conflict = conflictFromHosts
|
|
120
|
+
} else if (versionOrder > 0) {
|
|
121
|
+
kind = 'upgrade'
|
|
122
|
+
choices = ['remote', 'local']
|
|
123
|
+
defaultChoice = 'remote'
|
|
124
|
+
} else if (versionOrder < 0) {
|
|
125
|
+
kind = 'downgrade'
|
|
126
|
+
choices = ['remote', 'local', 'skip']
|
|
127
|
+
defaultChoice = 'local'
|
|
128
|
+
conflict = { type: 'downgrade' }
|
|
129
|
+
} else {
|
|
130
|
+
kind = 'spec-change'
|
|
131
|
+
choices = ['remote', 'local', 'skip']
|
|
132
|
+
defaultChoice = 'local'
|
|
133
|
+
conflict = { type: 'spec-change' }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
entries.push({
|
|
138
|
+
id: item.name,
|
|
139
|
+
name: item.name,
|
|
140
|
+
kind,
|
|
141
|
+
local: currentView,
|
|
142
|
+
remote: {
|
|
143
|
+
spec: item.spec,
|
|
144
|
+
version: item.version,
|
|
145
|
+
enabled: item.enabled,
|
|
146
|
+
hosts: sourceHosts,
|
|
147
|
+
variants,
|
|
148
|
+
},
|
|
149
|
+
conflict,
|
|
150
|
+
choices,
|
|
151
|
+
defaultChoice,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const [name, bundle] of local) {
|
|
156
|
+
if (seen.has(name)) continue
|
|
157
|
+
const view = localView(bundle)
|
|
158
|
+
const removable = view.removable === true
|
|
159
|
+
entries.push({
|
|
160
|
+
id: name,
|
|
161
|
+
name,
|
|
162
|
+
kind: 'local-only',
|
|
163
|
+
local: view,
|
|
164
|
+
remote: null,
|
|
165
|
+
conflict: null,
|
|
166
|
+
choices: removable ? ['local', 'remove'] : ['local'],
|
|
167
|
+
defaultChoice: 'local',
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
entries.sort((left, right) => {
|
|
172
|
+
const kindOrder = (KIND_ORDER.get(left.kind) ?? 99) - (KIND_ORDER.get(right.kind) ?? 99)
|
|
173
|
+
if (kindOrder !== 0) return kindOrder
|
|
174
|
+
return left.name.localeCompare(right.name)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const conflicts = entries.filter((entry) => entry.conflict !== null)
|
|
178
|
+
const counts = {
|
|
179
|
+
entries: entries.length,
|
|
180
|
+
conflicts: conflicts.length,
|
|
181
|
+
install: entries.filter((entry) => entry.kind === 'install').length,
|
|
182
|
+
upgrade: entries.filter((entry) => entry.kind === 'upgrade').length,
|
|
183
|
+
downgrade: entries.filter((entry) => entry.kind === 'downgrade').length,
|
|
184
|
+
specChange: entries.filter((entry) => entry.kind === 'spec-change').length,
|
|
185
|
+
enable: entries.filter((entry) => entry.kind === 'enable').length,
|
|
186
|
+
localOnly: entries.filter((entry) => entry.kind === 'local-only').length,
|
|
187
|
+
removable: entries.filter((entry) => entry.kind === 'local-only' && entry.choices.includes('remove')).length,
|
|
188
|
+
actionable: entries.filter((entry) => entry.defaultChoice === 'remote').length,
|
|
189
|
+
}
|
|
190
|
+
const remoteSummary = summarizeManifest(manifest)
|
|
191
|
+
return {
|
|
192
|
+
generatedAt: now,
|
|
193
|
+
remote: {
|
|
194
|
+
updatedAt: remoteSummary.updatedAt,
|
|
195
|
+
hostCount: remoteSummary.hostCount,
|
|
196
|
+
bundleCount: remoteSummary.bundleCount,
|
|
197
|
+
hosts: remoteSummary.hosts,
|
|
198
|
+
},
|
|
199
|
+
local: {
|
|
200
|
+
profileBundles: [...local.values()].length,
|
|
201
|
+
},
|
|
202
|
+
entries,
|
|
203
|
+
conflicts,
|
|
204
|
+
counts,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function selectPreviewEntries(preview, selections = {}) {
|
|
209
|
+
const chosen = []
|
|
210
|
+
for (const entry of preview.entries) {
|
|
211
|
+
const raw = selections[entry.id]
|
|
212
|
+
const choice = entry.choices.includes(raw) ? raw : entry.defaultChoice
|
|
213
|
+
if (choice !== 'remote' && choice !== 'remove') continue
|
|
214
|
+
chosen.push({ entry, choice })
|
|
215
|
+
}
|
|
216
|
+
return chosen
|
|
217
|
+
}
|