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/pushplan.js
ADDED
|
@@ -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
|
+
}
|