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.
@@ -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
+ }