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,200 @@
1
+ import { compareVersions } from './spec.js'
2
+ import { desiredBundles } from './manifest.js'
3
+
4
+ function versionOf(bundle) {
5
+ return typeof bundle?.version === 'string' ? bundle.version.trim() : ''
6
+ }
7
+
8
+ function specOf(bundle) {
9
+ return typeof bundle?.spec === 'string' ? bundle.spec.trim() : ''
10
+ }
11
+
12
+ function sameEntry(left, right) {
13
+ return specOf(left) === specOf(right) && versionOf(left) === versionOf(right) && (left?.enabled !== false) === (right?.enabled !== false)
14
+ }
15
+
16
+ /**
17
+ * Compare what the Gist already holds with what this host would publish.
18
+ *
19
+ * Three cases drive the decision, per the requested policy:
20
+ *
21
+ * - both sides have the bundle → `conflict`: keep the newest version by
22
+ * default, with an override per row;
23
+ * - only the remote side has it → `remote-only`: the user decides whether
24
+ * to keep it in the Gist;
25
+ * - only the local side has it → `local-only`: push it without asking.
26
+ */
27
+ export function buildPushPlan({ manifest, hostEntry, hostId, now = new Date().toISOString() }) {
28
+ const localBundles = Array.isArray(hostEntry?.bundles) ? hostEntry.bundles : []
29
+ const remote = desiredBundles(manifest, { includeDisabled: true, excludeHosts: hostId === undefined ? [] : [hostId] })
30
+ const remoteByName = new Map(remote.map((item) => [item.name, item]))
31
+ const localByName = new Map(localBundles.map((bundle) => [bundle.name, bundle]))
32
+
33
+ const entries = []
34
+ for (const bundle of localBundles) {
35
+ const counterpart = remoteByName.get(bundle.name) ?? null
36
+ if (counterpart === null) {
37
+ entries.push({
38
+ id: bundle.name,
39
+ name: bundle.name,
40
+ kind: 'local-only',
41
+ local: { spec: specOf(bundle), version: versionOf(bundle), enabled: bundle.enabled !== false },
42
+ remote: null,
43
+ choices: ['push'],
44
+ defaultChoice: 'push',
45
+ resolution: 'local',
46
+ })
47
+ continue
48
+ }
49
+ const localVersion = versionOf(bundle)
50
+ const remoteVersion = versionOf(counterpart)
51
+ const order = compareVersions(localVersion, remoteVersion)
52
+ if (order === 0 && sameEntry(bundle, counterpart)) {
53
+ entries.push({
54
+ id: bundle.name,
55
+ name: bundle.name,
56
+ kind: 'same',
57
+ local: { spec: specOf(bundle), version: localVersion, enabled: bundle.enabled !== false },
58
+ remote: {
59
+ spec: specOf(counterpart),
60
+ version: remoteVersion,
61
+ enabled: counterpart.enabled !== false,
62
+ hosts: counterpart.variants?.map((variant) => variant.hostId) ?? [],
63
+ },
64
+ choices: ['local'],
65
+ defaultChoice: 'local',
66
+ resolution: 'local',
67
+ })
68
+ continue
69
+ }
70
+ // The newest side wins by default; a tie keeps the local source.
71
+ const newest = order > 0 ? 'local' : order < 0 ? 'remote' : 'local'
72
+ entries.push({
73
+ id: bundle.name,
74
+ name: bundle.name,
75
+ kind: 'conflict',
76
+ local: { spec: specOf(bundle), version: localVersion, enabled: bundle.enabled !== false },
77
+ remote: {
78
+ spec: specOf(counterpart),
79
+ version: remoteVersion,
80
+ enabled: counterpart.enabled !== false,
81
+ hosts: counterpart.variants?.map((variant) => variant.hostId) ?? [],
82
+ },
83
+ newest,
84
+ choices: ['newest', 'local', 'remote'],
85
+ defaultChoice: 'newest',
86
+ resolution: newest,
87
+ })
88
+ }
89
+
90
+ for (const item of remote) {
91
+ if (localByName.has(item.name)) continue
92
+ entries.push({
93
+ id: item.name,
94
+ name: item.name,
95
+ kind: 'remote-only',
96
+ local: null,
97
+ remote: {
98
+ spec: item.spec,
99
+ version: item.version,
100
+ enabled: item.enabled !== false,
101
+ hosts: item.variants?.map((variant) => variant.hostId) ?? [],
102
+ },
103
+ choices: ['keep', 'drop'],
104
+ defaultChoice: 'keep',
105
+ resolution: 'keep',
106
+ })
107
+ }
108
+
109
+ entries.sort((left, right) => left.name.localeCompare(right.name))
110
+ const counts = {
111
+ entries: entries.length,
112
+ conflict: entries.filter((entry) => entry.kind === 'conflict').length,
113
+ remoteOnly: entries.filter((entry) => entry.kind === 'remote-only').length,
114
+ localOnly: entries.filter((entry) => entry.kind === 'local-only').length,
115
+ same: entries.filter((entry) => entry.kind === 'same').length,
116
+ needsChoice: entries.filter((entry) => entry.kind === 'remote-only').length,
117
+ pushed: entries.filter((entry) => entry.kind === 'local-only' || entry.kind === 'conflict' || entry.kind === 'same').length,
118
+ }
119
+ return { generatedAt: now, hostId, entries, counts }
120
+ }
121
+
122
+ function chooseConflict(entry, choice) {
123
+ const localNewest = entry.newest === 'local'
124
+ if (choice === 'local') return 'local'
125
+ if (choice === 'remote') return 'remote'
126
+ return localNewest ? 'local' : 'remote'
127
+ }
128
+
129
+ /**
130
+ * Turn a confirmed plan into the manifest to write.
131
+ *
132
+ * Conflict rows decide which spec/version the local host record publishes, so the
133
+ * Gist converges on one answer. `remote-only` rows left as `keep` are untouched;
134
+ * `drop` removes the bundle from every other host record.
135
+ */
136
+ export function applyPushPlan({ manifest, hostEntry, hostId, plan, selections = {}, now = new Date().toISOString() }) {
137
+ const chosen = (entry) => {
138
+ const raw = selections?.[entry.id]
139
+ return entry.choices.includes(raw) ? raw : entry.defaultChoice
140
+ }
141
+ const dropped = new Set()
142
+ const published = []
143
+ const kept = []
144
+ const pushed = []
145
+ const conflicts = []
146
+
147
+ const bundles = []
148
+ for (const bundle of Array.isArray(hostEntry?.bundles) ? hostEntry.bundles : []) {
149
+ const entry = plan.entries.find((item) => item.id === bundle.name)
150
+ if (entry === undefined || entry.kind === 'local-only') {
151
+ bundles.push(bundle)
152
+ pushed.push(bundle.name)
153
+ continue
154
+ }
155
+ if (entry.kind === 'same') {
156
+ bundles.push(bundle)
157
+ continue
158
+ }
159
+ const side = chooseConflict(entry, chosen(entry))
160
+ const source = side === 'remote' ? entry.remote : entry.local
161
+ conflicts.push({ name: bundle.name, side, version: source?.version ?? '', spec: source?.spec ?? '' })
162
+ const spec = source?.spec !== undefined && source.spec !== '' ? source.spec : bundle.spec
163
+ bundles.push({
164
+ ...bundle,
165
+ spec,
166
+ version: source?.version !== undefined && source.version !== '' ? source.version : bundle.version,
167
+ })
168
+ }
169
+
170
+ for (const entry of plan.entries) {
171
+ if (entry.kind !== 'remote-only') continue
172
+ if (chosen(entry) === 'drop') dropped.add(entry.name)
173
+ else kept.push(entry.name)
174
+ }
175
+
176
+ const hosts = {}
177
+ for (const [id, host] of Object.entries(manifest.hosts ?? {})) {
178
+ if (id === hostId) continue
179
+ const list = Array.isArray(host.bundles) ? host.bundles.filter((bundle) => !dropped.has(bundle.name)) : []
180
+ hosts[id] = { ...host, bundles: list }
181
+ }
182
+ hosts[hostId] = { ...hostEntry, bundles }
183
+
184
+ return {
185
+ manifest: {
186
+ ...manifest,
187
+ schemaVersion: manifest.schemaVersion,
188
+ generatedBy: manifest.generatedBy,
189
+ updatedAt: now,
190
+ lastWriter: hostId,
191
+ hosts,
192
+ },
193
+ report: {
194
+ pushed,
195
+ kept,
196
+ dropped: [...dropped],
197
+ conflicts,
198
+ },
199
+ }
200
+ }
package/lib/spec.js ADDED
@@ -0,0 +1,127 @@
1
+ import { homedir } from 'node:os'
2
+ import { isAbsolute, normalize, sep } from 'node:path'
3
+
4
+ const REJECTED_SCHEMES = /^(?:file|link|portal|workspace|catalog):/i
5
+ const PORTABLE_SCHEMES = /^(?:github:|git\+|https?:|npm:)/i
6
+ const SCP_LIKE = /^git@[^:\s]+:[^ \t]+$/
7
+
8
+ export function isRecord(value) {
9
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
10
+ }
11
+
12
+ export function expandHome(value) {
13
+ if (typeof value !== 'string') return ''
14
+ const trimmed = value.trim()
15
+ if (trimmed === '~') return homedir()
16
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
17
+ return normalize(`${homedir()}${sep}${trimmed.slice(2)}`)
18
+ }
19
+ return trimmed
20
+ }
21
+
22
+ export function normalizeRepo(value) {
23
+ if (typeof value !== 'string') return ''
24
+ let text = value.trim()
25
+ if (text === '') return ''
26
+ text = text.replace(/^git\+/i, '')
27
+ text = text.replace(/^https?:\/\/github\.com\//i, '')
28
+ text = text.replace(/^git@github\.com:/i, '')
29
+ text = text.replace(/\.git$/i, '')
30
+ text = text.replace(/^\/+|\/+$/g, '')
31
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(text)) return ''
32
+ return text
33
+ }
34
+
35
+ export function normalizeBranch(value) {
36
+ if (typeof value !== 'string') return 'main'
37
+ const branch = value.trim()
38
+ if (branch === '' || branch.length > 200) return 'main'
39
+ if (branch.startsWith('-') || branch.includes('..') || /\s/.test(branch)) return 'main'
40
+ return branch
41
+ }
42
+
43
+ export function normalizeManifestPath(value) {
44
+ if (typeof value !== 'string') return 'dsh-plugins.json'
45
+ const path = value.trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
46
+ if (path === '' || path.length > 500) return 'dsh-plugins.json'
47
+ if (path.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')) {
48
+ return 'dsh-plugins.json'
49
+ }
50
+ return path
51
+ }
52
+
53
+ export function isLocalSpec(value) {
54
+ if (typeof value !== 'string') return false
55
+ const spec = value.trim()
56
+ if (spec === '') return false
57
+ if (REJECTED_SCHEMES.test(spec)) return true
58
+ if (spec.startsWith('/') || spec.startsWith('\\') || spec.startsWith('~')) return true
59
+ if (/^[A-Za-z]:[\\/]/.test(spec)) return true
60
+ if (spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('.\\') || spec.startsWith('..\\')) {
61
+ return true
62
+ }
63
+ if (spec.includes('\\')) return true
64
+ if (spec.split('/').some((segment) => segment === '..')) return true
65
+ return isAbsolute(spec)
66
+ }
67
+
68
+ export function isPortableSpec(value) {
69
+ if (typeof value !== 'string') return false
70
+ const spec = value.trim()
71
+ if (spec === '' || spec.length > 2048) return false
72
+ if (isLocalSpec(spec)) return false
73
+ if (PORTABLE_SCHEMES.test(spec) || SCP_LIKE.test(spec)) return true
74
+ if (/^@[^/\s]+\/[^/\s]+(?:\/[^/\s]+)?(@[^\s]+)?$/.test(spec)) return true
75
+ if (/^[^/@\s][^/\s]*(?:@[^\s]+)?$/.test(spec)) return true
76
+ return false
77
+ }
78
+
79
+ export function parseVersion(value) {
80
+ if (typeof value !== 'string') return null
81
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim())
82
+ if (match === null) return null
83
+ return {
84
+ major: Number(match[1]),
85
+ minor: Number(match[2]),
86
+ patch: Number(match[3]),
87
+ prerelease: match[4] === undefined ? [] : match[4].split('.'),
88
+ }
89
+ }
90
+
91
+ function compareIdentifiers(left, right) {
92
+ const leftNumber = /^\d+$/.test(left) ? Number(left) : null
93
+ const rightNumber = /^\d+$/.test(right) ? Number(right) : null
94
+ if (leftNumber !== null && rightNumber !== null) return leftNumber - rightNumber
95
+ if (leftNumber !== null) return -1
96
+ if (rightNumber !== null) return 1
97
+ return left.localeCompare(right)
98
+ }
99
+
100
+ export function compareVersions(left, right) {
101
+ const a = typeof left === 'object' && left !== null ? left : parseVersion(left)
102
+ const b = typeof right === 'object' && right !== null ? right : parseVersion(right)
103
+ if (a === null && b === null) return 0
104
+ if (a === null) return -1
105
+ if (b === null) return 1
106
+ for (const key of ['major', 'minor', 'patch']) {
107
+ if (a[key] !== b[key]) return a[key] - b[key]
108
+ }
109
+ if (a.prerelease.length === 0 && b.prerelease.length > 0) return 1
110
+ if (b.prerelease.length === 0 && a.prerelease.length > 0) return -1
111
+ const length = Math.max(a.prerelease.length, b.prerelease.length)
112
+ for (let index = 0; index < length; index += 1) {
113
+ const leftPart = a.prerelease[index]
114
+ const rightPart = b.prerelease[index]
115
+ if (leftPart === undefined) return -1
116
+ if (rightPart === undefined) return 1
117
+ const compared = compareIdentifiers(leftPart, rightPart)
118
+ if (compared !== 0) return compared
119
+ }
120
+ return 0
121
+ }
122
+
123
+ export function formatIso(value, fallback = new Date().toISOString()) {
124
+ if (typeof value !== 'string') return fallback
125
+ const date = new Date(value)
126
+ return Number.isNaN(date.getTime()) ? fallback : date.toISOString()
127
+ }
package/lib/state.js ADDED
@@ -0,0 +1,110 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { expandHome, formatIso, isRecord } from './spec.js'
5
+
6
+ export const STATE_VERSION = 1
7
+ const MAX_HISTORY = 20
8
+ const MAX_RESULTS = 50
9
+
10
+ export function resolveStatePath(config = {}) {
11
+ if (typeof config.stateFile === 'string' && config.stateFile.trim() !== '') {
12
+ return expandHome(config.stateFile)
13
+ }
14
+ const home = typeof config.dshHome === 'string' && config.dshHome.trim() !== ''
15
+ ? expandHome(config.dshHome)
16
+ : process.env.DSH_HOME || join(homedir(), '.dsh')
17
+ return join(home, 'plugin-sync', 'state.json')
18
+ }
19
+
20
+ export function emptyState(hostId = '') {
21
+ return {
22
+ version: STATE_VERSION,
23
+ hostId,
24
+ gistName: null,
25
+ gistId: null,
26
+ gistUrl: null,
27
+ lastPreviewAt: null,
28
+ lastApplyAt: null,
29
+ lastPushAt: null,
30
+ lastSyncAt: null,
31
+ lastPullAt: null,
32
+ lastRun: null,
33
+ remote: null,
34
+ lastResults: [],
35
+ history: [],
36
+ pendingUpgrades: [],
37
+ manualUpdates: [],
38
+ updatedAt: null,
39
+ }
40
+ }
41
+
42
+ function normalizeResult(value) {
43
+ if (!isRecord(value) || typeof value.name !== 'string') return null
44
+ return {
45
+ name: value.name,
46
+ ...(typeof value.spec === 'string' ? { spec: value.spec } : {}),
47
+ status: typeof value.status === 'string' ? value.status : 'unknown',
48
+ ...(typeof value.application === 'string' ? { application: value.application } : {}),
49
+ ...(typeof value.kind === 'string' ? { kind: value.kind } : {}),
50
+ ...(value.restartRequired === true ? { restartRequired: true } : {}),
51
+ ...(typeof value.error === 'string' ? { error: value.error } : {}),
52
+ }
53
+ }
54
+
55
+ export function normalizeState(value, hostId = '') {
56
+ if (!isRecord(value)) return emptyState(hostId)
57
+ const state = emptyState(hostId)
58
+ state.hostId = typeof value.hostId === 'string' && value.hostId !== '' ? value.hostId : hostId
59
+ if (typeof value.gistName === 'string' && value.gistName !== '') state.gistName = value.gistName
60
+ if (typeof value.gistId === 'string' && value.gistId !== '') state.gistId = value.gistId
61
+ if (typeof value.gistUrl === 'string' && value.gistUrl !== '') state.gistUrl = value.gistUrl
62
+ for (const key of ['lastPreviewAt', 'lastApplyAt', 'lastPushAt', 'lastSyncAt', 'lastPullAt']) {
63
+ if (typeof value[key] === 'string') state[key] = formatIso(value[key])
64
+ }
65
+ if (isRecord(value.lastRun)) state.lastRun = value.lastRun
66
+ if (isRecord(value.remote)) state.remote = value.remote
67
+ if (Array.isArray(value.pendingUpgrades)) {
68
+ state.pendingUpgrades = value.pendingUpgrades
69
+ .filter((item) => isRecord(item) && typeof item.name === 'string')
70
+ .slice(0, 20)
71
+ }
72
+ if (Array.isArray(value.manualUpdates)) {
73
+ state.manualUpdates = value.manualUpdates
74
+ .filter((item) => isRecord(item) && typeof item.name === 'string')
75
+ .slice(0, 20)
76
+ }
77
+ if (Array.isArray(value.lastResults)) {
78
+ state.lastResults = value.lastResults.map(normalizeResult).filter((item) => item !== null).slice(0, MAX_RESULTS)
79
+ }
80
+ if (Array.isArray(value.history)) {
81
+ state.history = value.history.filter((item) => isRecord(item)).slice(0, MAX_HISTORY)
82
+ }
83
+ if (typeof value.updatedAt === 'string') state.updatedAt = formatIso(value.updatedAt)
84
+ return state
85
+ }
86
+
87
+ export async function readState(path, hostId = '') {
88
+ try {
89
+ const text = await readFile(path, 'utf8')
90
+ return normalizeState(JSON.parse(text), hostId)
91
+ } catch (error) {
92
+ if (error !== null && typeof error === 'object' && error.code === 'ENOENT') return emptyState(hostId)
93
+ return {
94
+ ...emptyState(hostId),
95
+ loadError: error instanceof Error ? error.message : String(error),
96
+ }
97
+ }
98
+ }
99
+
100
+ export async function writeState(path, state) {
101
+ const normalized = normalizeState({
102
+ ...state,
103
+ updatedAt: new Date().toISOString(),
104
+ lastResults: Array.isArray(state.lastResults) ? state.lastResults.slice(0, MAX_RESULTS) : [],
105
+ history: Array.isArray(state.history) ? state.history.slice(0, MAX_HISTORY) : [],
106
+ }, state.hostId)
107
+ await mkdir(dirname(path), { recursive: true })
108
+ await writeFile(path, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
109
+ return normalized
110
+ }