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/github.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { emptyManifest, normalizeManifest } from './manifest.js'
|
|
3
|
+
import { expandHome } from './spec.js'
|
|
4
|
+
|
|
5
|
+
const USER_AGENT = 'dsh-plugin-syncup/2.2.0'
|
|
6
|
+
const MAX_MANIFEST_BYTES = 1_000_000
|
|
7
|
+
|
|
8
|
+
export class GitHubError extends Error {
|
|
9
|
+
constructor(message, { code = 'github-error', status = undefined, retryable = false, body = undefined } = {}) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = 'GitHubError'
|
|
12
|
+
this.code = code
|
|
13
|
+
this.status = status
|
|
14
|
+
this.retryable = retryable
|
|
15
|
+
this.body = body
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveToken({ token = '', tokenEnv = 'GITHUB_TOKEN', tokenFile = '', env = process.env } = {}) {
|
|
20
|
+
if (typeof token === 'string' && token.trim() !== '') {
|
|
21
|
+
return { token: token.trim(), source: 'config' }
|
|
22
|
+
}
|
|
23
|
+
if (typeof tokenEnv === 'string' && tokenEnv.trim() !== '') {
|
|
24
|
+
const value = env[tokenEnv.trim()]
|
|
25
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
26
|
+
return { token: value.trim(), source: `env:${tokenEnv.trim()}` }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const file = expandHome(tokenFile)
|
|
30
|
+
if (file !== '') {
|
|
31
|
+
try {
|
|
32
|
+
const value = readFileSync(file, 'utf8').trim()
|
|
33
|
+
if (value !== '') return { token: value, source: 'file' }
|
|
34
|
+
} catch {
|
|
35
|
+
return { token: '', source: null }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { token: '', source: null }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseApiBase(value) {
|
|
42
|
+
const fallback = 'https://api.github.com'
|
|
43
|
+
if (typeof value !== 'string' || value.trim() === '') return fallback
|
|
44
|
+
let url
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(value.trim())
|
|
47
|
+
} catch {
|
|
48
|
+
return fallback
|
|
49
|
+
}
|
|
50
|
+
url.search = ''
|
|
51
|
+
url.hash = ''
|
|
52
|
+
return url.toString().replace(/\/+$/, '')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function errorMessageFromBody(body, status) {
|
|
56
|
+
if (typeof body === 'string' && body.trim() !== '') return body.trim().slice(0, 500)
|
|
57
|
+
if (body !== null && typeof body === 'object' && typeof body.message === 'string') {
|
|
58
|
+
const details = Array.isArray(body.errors)
|
|
59
|
+
? body.errors
|
|
60
|
+
.map((item) => (typeof item === 'string' ? item : item?.message ?? item?.code ?? ''))
|
|
61
|
+
.filter((item) => typeof item === 'string' && item !== '')
|
|
62
|
+
: []
|
|
63
|
+
const suffix = details.length === 0 ? '' : ` (${details.join('; ')})`
|
|
64
|
+
return `${body.message}${suffix}`.slice(0, 500)
|
|
65
|
+
}
|
|
66
|
+
return `GitHub API request failed with status ${status}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function gistVersion(gist) {
|
|
70
|
+
if (gist !== null && typeof gist === 'object' && Array.isArray(gist.history)) {
|
|
71
|
+
const version = gist.history[0]?.version
|
|
72
|
+
if (typeof version === 'string' && version !== '') return version
|
|
73
|
+
}
|
|
74
|
+
return typeof gist?.updated_at === 'string' ? gist.updated_at : null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function gistSummary(gist) {
|
|
78
|
+
return {
|
|
79
|
+
gistId: typeof gist?.id === 'string' ? gist.id : null,
|
|
80
|
+
htmlUrl: typeof gist?.html_url === 'string' ? gist.html_url : null,
|
|
81
|
+
description: typeof gist?.description === 'string' ? gist.description : null,
|
|
82
|
+
updatedAt: typeof gist?.updated_at === 'string' ? gist.updated_at : null,
|
|
83
|
+
version: gistVersion(gist),
|
|
84
|
+
public: gist?.public === true,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function gistFileName(value) {
|
|
89
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : 'dsh-plugins.json'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createGitHubClient({
|
|
93
|
+
apiBaseUrl = 'https://api.github.com',
|
|
94
|
+
token = '',
|
|
95
|
+
timeoutMs = 20_000,
|
|
96
|
+
fetchImpl = globalThis.fetch,
|
|
97
|
+
} = {}) {
|
|
98
|
+
if (typeof fetchImpl !== 'function') throw new GitHubError('fetch is unavailable in this Host', { code: 'no-fetch' })
|
|
99
|
+
const base = parseApiBase(apiBaseUrl)
|
|
100
|
+
|
|
101
|
+
async function requestUrl(url, { method = 'GET', body, headers = {}, allow404 = false, extraSignal } = {}) {
|
|
102
|
+
const requestHeaders = {
|
|
103
|
+
accept: 'application/vnd.github+json',
|
|
104
|
+
'x-github-api-version': '2022-11-28',
|
|
105
|
+
'user-agent': USER_AGENT,
|
|
106
|
+
...headers,
|
|
107
|
+
}
|
|
108
|
+
if (token !== '') requestHeaders.authorization = `Bearer ${token}`
|
|
109
|
+
if (body !== undefined) requestHeaders['content-type'] = 'application/json'
|
|
110
|
+
const signals = [AbortSignal.timeout(timeoutMs)]
|
|
111
|
+
for (const candidate of [extraSignal]) {
|
|
112
|
+
if (candidate !== undefined) signals.push(candidate)
|
|
113
|
+
}
|
|
114
|
+
const combined = signals.length === 1 ? signals[0] : AbortSignal.any(signals)
|
|
115
|
+
let response
|
|
116
|
+
try {
|
|
117
|
+
response = await fetchImpl(url, {
|
|
118
|
+
method,
|
|
119
|
+
headers: requestHeaders,
|
|
120
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
121
|
+
signal: combined,
|
|
122
|
+
})
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (combined.aborted) {
|
|
125
|
+
throw new GitHubError('GitHub request was aborted or timed out', { code: 'timeout', retryable: true })
|
|
126
|
+
}
|
|
127
|
+
throw new GitHubError(`GitHub request failed: ${error instanceof Error ? error.message : String(error)}`, {
|
|
128
|
+
code: 'network',
|
|
129
|
+
retryable: true,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
const raw = await response.text()
|
|
133
|
+
let parsed = null
|
|
134
|
+
if (raw.trim() !== '') {
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(raw)
|
|
137
|
+
} catch {
|
|
138
|
+
parsed = raw
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (response.ok) return { status: response.status, body: parsed, headers: response.headers }
|
|
142
|
+
if (response.status === 404 && allow404) return null
|
|
143
|
+
if (response.status === 409 || response.status === 412) {
|
|
144
|
+
throw new GitHubError(errorMessageFromBody(parsed, response.status), {
|
|
145
|
+
code: 'conflict',
|
|
146
|
+
status: response.status,
|
|
147
|
+
retryable: true,
|
|
148
|
+
body: parsed,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
const retryable = response.status === 429 || response.status >= 500
|
|
152
|
+
const code = response.status === 401 || response.status === 403
|
|
153
|
+
? 'auth'
|
|
154
|
+
: response.status === 404
|
|
155
|
+
? 'not-found'
|
|
156
|
+
: retryable
|
|
157
|
+
? 'server'
|
|
158
|
+
: 'github-error'
|
|
159
|
+
throw new GitHubError(errorMessageFromBody(parsed, response.status), {
|
|
160
|
+
code,
|
|
161
|
+
status: response.status,
|
|
162
|
+
retryable,
|
|
163
|
+
body: parsed,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function readGist(gistId, fileName = 'dsh-plugins.json', { extraSignal } = {}) {
|
|
168
|
+
if (typeof gistId !== 'string' || gistId === '') throw new GitHubError('A Gist id is required', { code: 'invalid-gist' })
|
|
169
|
+
const name = gistFileName(fileName)
|
|
170
|
+
const response = await requestUrl(`${base}/gists/${encodeURIComponent(gistId)}`, {
|
|
171
|
+
allow404: true,
|
|
172
|
+
extraSignal,
|
|
173
|
+
})
|
|
174
|
+
if (response === null) {
|
|
175
|
+
throw new GitHubError(`Gist ${gistId} was not found or is not accessible with this token`, { code: 'not-found' })
|
|
176
|
+
}
|
|
177
|
+
const gist = response.body
|
|
178
|
+
const summary = gistSummary(gist)
|
|
179
|
+
const file = gist !== null && typeof gist === 'object' && gist.files !== null && typeof gist.files === 'object'
|
|
180
|
+
? gist.files[name]
|
|
181
|
+
: undefined
|
|
182
|
+
if (file === undefined || file === null) {
|
|
183
|
+
return {
|
|
184
|
+
...summary,
|
|
185
|
+
exists: false,
|
|
186
|
+
fileName: name,
|
|
187
|
+
manifest: emptyManifest(),
|
|
188
|
+
etag: response.headers.get('etag'),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
let text = typeof file.content === 'string' ? file.content : ''
|
|
192
|
+
if (file.truncated === true || (text === '' && typeof file.raw_url === 'string')) {
|
|
193
|
+
if (typeof file.raw_url !== 'string' || file.raw_url === '') {
|
|
194
|
+
throw new GitHubError(`Gist file ${name} is truncated and has no raw URL`, { code: 'invalid-response' })
|
|
195
|
+
}
|
|
196
|
+
const raw = await requestUrl(file.raw_url, {
|
|
197
|
+
headers: { accept: 'text/plain' },
|
|
198
|
+
extraSignal,
|
|
199
|
+
})
|
|
200
|
+
text = typeof raw.body === 'string'
|
|
201
|
+
? raw.body
|
|
202
|
+
: raw.body === null || raw.body === undefined ? '' : JSON.stringify(raw.body)
|
|
203
|
+
}
|
|
204
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_MANIFEST_BYTES) {
|
|
205
|
+
throw new GitHubError(`Gist manifest ${name} is larger than 1 MB`, { code: 'manifest-too-large' })
|
|
206
|
+
}
|
|
207
|
+
let parsed
|
|
208
|
+
try {
|
|
209
|
+
parsed = JSON.parse(text)
|
|
210
|
+
} catch {
|
|
211
|
+
throw new GitHubError(`Gist file ${name} is not valid JSON`, { code: 'invalid-manifest' })
|
|
212
|
+
}
|
|
213
|
+
const manifest = normalizeManifest(parsed, { source: `gist ${gistId}/${name}`, requireShape: true })
|
|
214
|
+
return {
|
|
215
|
+
...summary,
|
|
216
|
+
exists: true,
|
|
217
|
+
fileName: name,
|
|
218
|
+
manifest,
|
|
219
|
+
etag: response.headers.get('etag'),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function listGists({ extraSignal } = {}) {
|
|
224
|
+
const response = await requestUrl(`${base}/gists?per_page=100`, { extraSignal })
|
|
225
|
+
if (!Array.isArray(response.body)) {
|
|
226
|
+
throw new GitHubError('GitHub did not return a Gist list', { code: 'invalid-response' })
|
|
227
|
+
}
|
|
228
|
+
return response.body.map(gistSummary)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function createGist({ description = 'DSH plugin inventory', fileName = 'dsh-plugins.json', manifest, extraSignal } = {}) {
|
|
232
|
+
if (token === '') {
|
|
233
|
+
throw new GitHubError('A GitHub token is required to create a Gist', { code: 'no-token' })
|
|
234
|
+
}
|
|
235
|
+
const name = gistFileName(fileName)
|
|
236
|
+
const text = `${JSON.stringify(manifest, null, 2)}\n`
|
|
237
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_MANIFEST_BYTES) {
|
|
238
|
+
throw new GitHubError('Manifest is larger than 1 MB', { code: 'manifest-too-large' })
|
|
239
|
+
}
|
|
240
|
+
const response = await requestUrl(`${base}/gists`, {
|
|
241
|
+
method: 'POST',
|
|
242
|
+
body: {
|
|
243
|
+
description,
|
|
244
|
+
public: false,
|
|
245
|
+
files: { [name]: { content: text } },
|
|
246
|
+
},
|
|
247
|
+
extraSignal,
|
|
248
|
+
})
|
|
249
|
+
return { ...gistSummary(response.body), fileName: name }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Update a Gist's manifest file.
|
|
254
|
+
*
|
|
255
|
+
* GitHub rejects conditional headers on this endpoint ("Conditional request
|
|
256
|
+
* headers are not allowed in unsafe requests unless supported by the
|
|
257
|
+
* endpoint"), so conflict detection uses the read-then-merge retry in the
|
|
258
|
+
* caller instead of If-Match. `expectedVersion` is only used to detect that
|
|
259
|
+
* someone else changed the Gist between the caller's read and this write.
|
|
260
|
+
*/
|
|
261
|
+
async function updateGist(gistId, {
|
|
262
|
+
description = 'DSH plugin inventory',
|
|
263
|
+
fileName = 'dsh-plugins.json',
|
|
264
|
+
manifest,
|
|
265
|
+
expectedVersion = null,
|
|
266
|
+
extraSignal,
|
|
267
|
+
} = {}) {
|
|
268
|
+
if (token === '') {
|
|
269
|
+
throw new GitHubError('A GitHub token is required to update a Gist', { code: 'no-token' })
|
|
270
|
+
}
|
|
271
|
+
const name = gistFileName(fileName)
|
|
272
|
+
const text = `${JSON.stringify(manifest, null, 2)}\n`
|
|
273
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_MANIFEST_BYTES) {
|
|
274
|
+
throw new GitHubError('Manifest is larger than 1 MB', { code: 'manifest-too-large' })
|
|
275
|
+
}
|
|
276
|
+
if (expectedVersion !== null) {
|
|
277
|
+
const current = await requestUrl(`${base}/gists/${encodeURIComponent(gistId)}`, { extraSignal })
|
|
278
|
+
const currentVersion = gistVersion(current.body)
|
|
279
|
+
if (currentVersion !== null && currentVersion !== expectedVersion) {
|
|
280
|
+
throw new GitHubError('The Gist changed after it was read; retry the update', {
|
|
281
|
+
code: 'conflict',
|
|
282
|
+
status: 412,
|
|
283
|
+
retryable: true,
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const response = await requestUrl(`${base}/gists/${encodeURIComponent(gistId)}`, {
|
|
288
|
+
method: 'PATCH',
|
|
289
|
+
body: {
|
|
290
|
+
description,
|
|
291
|
+
files: { [name]: { content: text } },
|
|
292
|
+
},
|
|
293
|
+
extraSignal,
|
|
294
|
+
})
|
|
295
|
+
return { ...gistSummary(response.body), fileName: name }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
apiBaseUrl: base,
|
|
300
|
+
readGist,
|
|
301
|
+
listGists,
|
|
302
|
+
createGist,
|
|
303
|
+
updateGist,
|
|
304
|
+
}
|
|
305
|
+
}
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearCredential,
|
|
3
|
+
ConfigError,
|
|
4
|
+
describeCredential,
|
|
5
|
+
readConfigSnapshot,
|
|
6
|
+
setCredential,
|
|
7
|
+
writeConfigValues,
|
|
8
|
+
} from './config.js'
|
|
9
|
+
|
|
10
|
+
const API_PREFIX = '/dsh-plugin-syncup/api/v2'
|
|
11
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
12
|
+
|
|
13
|
+
function sendJson(response, status, payload) {
|
|
14
|
+
const body = `${JSON.stringify(payload)}\n`
|
|
15
|
+
response.writeHead(status, {
|
|
16
|
+
'content-type': 'application/json; charset=utf-8',
|
|
17
|
+
'cache-control': 'no-store',
|
|
18
|
+
'content-length': Buffer.byteLength(body),
|
|
19
|
+
})
|
|
20
|
+
response.end(body)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sendError(response, status, code, message) {
|
|
24
|
+
sendJson(response, status, { ok: false, error: { code, message } })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sameOrigin(request) {
|
|
28
|
+
const origin = request.headers.origin
|
|
29
|
+
if (typeof origin !== 'string' || origin === '') return true
|
|
30
|
+
try {
|
|
31
|
+
return new URL(origin).host === request.headers.host
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readJson(request) {
|
|
38
|
+
const length = Number(request.headers['content-length'] ?? 0)
|
|
39
|
+
if (Number.isFinite(length) && length > MAX_BODY_BYTES) {
|
|
40
|
+
const error = new Error('Request body is too large')
|
|
41
|
+
error.status = 413
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
let total = 0
|
|
45
|
+
const chunks = []
|
|
46
|
+
for await (const chunk of request) {
|
|
47
|
+
total += chunk.length
|
|
48
|
+
if (total > MAX_BODY_BYTES) {
|
|
49
|
+
const error = new Error('Request body is too large')
|
|
50
|
+
error.status = 413
|
|
51
|
+
throw error
|
|
52
|
+
}
|
|
53
|
+
chunks.push(chunk)
|
|
54
|
+
}
|
|
55
|
+
if (total === 0) return {}
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
58
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
59
|
+
throw new Error('JSON body must be an object')
|
|
60
|
+
}
|
|
61
|
+
return parsed
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const failure = new Error(`Invalid JSON body: ${error instanceof Error ? error.message : String(error)}`)
|
|
64
|
+
failure.status = 400
|
|
65
|
+
throw failure
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isRecord(value) {
|
|
70
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function methodGuard(request, response, allowed) {
|
|
74
|
+
if (allowed.includes(request.method)) return true
|
|
75
|
+
response.writeHead(405, { allow: allowed.join(', ') })
|
|
76
|
+
response.end()
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function registerApiRoutes({ ctx, runtime, config }) {
|
|
81
|
+
const routes = [
|
|
82
|
+
{
|
|
83
|
+
name: 'status',
|
|
84
|
+
methods: ['GET'],
|
|
85
|
+
path: `${API_PREFIX}/status`,
|
|
86
|
+
handle: async (_request, response) => {
|
|
87
|
+
const status = await runtime.status()
|
|
88
|
+
const credential = await describeCredential(ctx)
|
|
89
|
+
if (credential.available) {
|
|
90
|
+
status.token = {
|
|
91
|
+
configured: credential.configured,
|
|
92
|
+
source: credential.source,
|
|
93
|
+
writable: credential.writable,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const configSnapshot = readConfigSnapshot(ctx, config)
|
|
97
|
+
sendJson(response, 200, {
|
|
98
|
+
...status,
|
|
99
|
+
configSnapshot,
|
|
100
|
+
})
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'config',
|
|
105
|
+
methods: ['GET', 'POST'],
|
|
106
|
+
path: `${API_PREFIX}/config`,
|
|
107
|
+
handle: async (request, response) => {
|
|
108
|
+
if (request.method === 'GET') {
|
|
109
|
+
sendJson(response, 200, {
|
|
110
|
+
ok: true,
|
|
111
|
+
config: readConfigSnapshot(ctx, config),
|
|
112
|
+
credential: await describeCredential(ctx),
|
|
113
|
+
})
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
const body = await readJson(request)
|
|
117
|
+
if (!isRecord(body.values)) {
|
|
118
|
+
sendError(response, 400, 'invalid-request', 'values must be an object')
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (body.clearToken === true) {
|
|
122
|
+
await clearCredential(ctx)
|
|
123
|
+
} else if (typeof body.token === 'string' && body.token.trim() !== '') {
|
|
124
|
+
await setCredential(ctx, body.token)
|
|
125
|
+
}
|
|
126
|
+
const values = await writeConfigValues(ctx, body.values, config)
|
|
127
|
+
sendJson(response, 200, {
|
|
128
|
+
ok: true,
|
|
129
|
+
values,
|
|
130
|
+
credential: await describeCredential(ctx),
|
|
131
|
+
})
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'jobs',
|
|
136
|
+
methods: ['GET'],
|
|
137
|
+
path: `${API_PREFIX}/jobs`,
|
|
138
|
+
handle: async (request, response) => {
|
|
139
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1')
|
|
140
|
+
const jobId = url.searchParams.get('id')
|
|
141
|
+
if (typeof jobId === 'string' && jobId !== '') {
|
|
142
|
+
const job = runtime.job(jobId)
|
|
143
|
+
if (job === null) {
|
|
144
|
+
sendError(response, 404, 'job-not-found', `No job with id ${jobId}`)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
sendJson(response, 200, { ok: true, job })
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
const kind = url.searchParams.get('kind')
|
|
151
|
+
sendJson(response, 200, {
|
|
152
|
+
ok: true,
|
|
153
|
+
latest: runtime.latestJob(kind === null ? undefined : kind),
|
|
154
|
+
jobs: runtime.jobs(kind === null ? undefined : kind),
|
|
155
|
+
})
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'latest',
|
|
160
|
+
methods: ['GET', 'POST'],
|
|
161
|
+
path: `${API_PREFIX}/latest`,
|
|
162
|
+
handle: async (request, response) => {
|
|
163
|
+
if (request.method === 'GET') {
|
|
164
|
+
// `cached=1` restores the last result without re-querying the registries,
|
|
165
|
+
// so a page that remounts mid-work does not start a second check.
|
|
166
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1')
|
|
167
|
+
if (url.searchParams.get('cached') === '1') {
|
|
168
|
+
const cached = runtime.cachedLatest()
|
|
169
|
+
sendJson(response, 200, cached === null
|
|
170
|
+
? { ok: true, cached: false, entries: [], counts: { entries: 0, outdated: 0, current: 0, unsupported: 0, failed: 0 } }
|
|
171
|
+
: { ok: true, cached: true, ...cached })
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
const result = await runtime.checkLatest()
|
|
175
|
+
sendJson(response, 200, result)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
const body = await readJson(request)
|
|
179
|
+
if (!Array.isArray(body.names)) {
|
|
180
|
+
sendError(response, 400, 'invalid-request', 'names must be an array of bundle names')
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
const result = await runtime.applyLatest(body.names, {
|
|
184
|
+
channel: typeof body.channel === 'string' ? body.channel : null,
|
|
185
|
+
channels: isRecord(body.channels) ? body.channels : {},
|
|
186
|
+
})
|
|
187
|
+
sendJson(response, 200, result)
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'preview',
|
|
192
|
+
methods: ['POST'],
|
|
193
|
+
path: `${API_PREFIX}/preview`,
|
|
194
|
+
handle: async (_request, response) => {
|
|
195
|
+
const preview = await runtime.preview()
|
|
196
|
+
sendJson(response, 200, preview)
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'apply',
|
|
201
|
+
methods: ['POST'],
|
|
202
|
+
path: `${API_PREFIX}/apply`,
|
|
203
|
+
handle: async (request, response) => {
|
|
204
|
+
const body = await readJson(request)
|
|
205
|
+
if (typeof body.previewId !== 'string' || body.previewId === '') {
|
|
206
|
+
sendError(response, 400, 'invalid-request', 'previewId is required')
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
if (!isRecord(body.selections)) {
|
|
210
|
+
sendError(response, 400, 'invalid-request', 'selections must be an object')
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
const result = await runtime.apply(body.previewId, body.selections)
|
|
214
|
+
sendJson(response, 200, result)
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: 'push',
|
|
219
|
+
methods: ['POST'],
|
|
220
|
+
path: `${API_PREFIX}/push`,
|
|
221
|
+
handle: async (request, response) => {
|
|
222
|
+
const body = await readJson(request)
|
|
223
|
+
if (body.confirm !== true) {
|
|
224
|
+
sendError(response, 400, 'confirm-required', 'Set confirm=true to update the remote manifest')
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
const result = await runtime.push({
|
|
228
|
+
planId: typeof body.planId === 'string' && body.planId !== '' ? body.planId : null,
|
|
229
|
+
selections: isRecord(body.selections) ? body.selections : {},
|
|
230
|
+
})
|
|
231
|
+
sendJson(response, 200, result)
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: 'plan-push',
|
|
236
|
+
methods: ['POST'],
|
|
237
|
+
path: `${API_PREFIX}/push/plan`,
|
|
238
|
+
handle: async (_request, response) => {
|
|
239
|
+
const result = await runtime.planPush()
|
|
240
|
+
sendJson(response, 200, result)
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
const mount = (hostCtx) => {
|
|
246
|
+
hostCtx.effect(() => {
|
|
247
|
+
const disposers = []
|
|
248
|
+
for (const route of routes) {
|
|
249
|
+
disposers.push(hostCtx.webServer.register({
|
|
250
|
+
kind: 'exact',
|
|
251
|
+
path: route.path,
|
|
252
|
+
handler: async (request, response) => {
|
|
253
|
+
if (!sameOrigin(request)) {
|
|
254
|
+
sendError(response, 403, 'cross-origin', 'Cross-origin requests are not allowed')
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
if (!methodGuard(request, response, route.methods)) return
|
|
258
|
+
try {
|
|
259
|
+
await route.handle(request, response)
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error instanceof ConfigError) {
|
|
262
|
+
sendError(response, 400, error.code, error.message)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
const status = typeof error?.status === 'number' ? error.status : 500
|
|
266
|
+
const code = typeof error?.code === 'string' ? error.code : 'operation-failed'
|
|
267
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
268
|
+
sendError(response, status, code, message)
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
}))
|
|
272
|
+
}
|
|
273
|
+
return () => {
|
|
274
|
+
for (const dispose of disposers) dispose()
|
|
275
|
+
}
|
|
276
|
+
}, 'plugin-sync: API routes')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const existing = ctx.get('webServer')
|
|
280
|
+
if (existing !== undefined && existing !== null) {
|
|
281
|
+
mount({ webServer: existing, effect: ctx.effect.bind(ctx) })
|
|
282
|
+
} else {
|
|
283
|
+
ctx.inject(['webServer'], mount)
|
|
284
|
+
}
|
|
285
|
+
}
|
package/lib/jobs.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
const MAX_JOBS = 10
|
|
4
|
+
const MAX_LINES = 400
|
|
5
|
+
|
|
6
|
+
function nowIso(now) {
|
|
7
|
+
return now().toISOString()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function clampText(value) {
|
|
11
|
+
const text = typeof value === 'string' ? value : String(value ?? '')
|
|
12
|
+
return text.length > 2000 ? `${text.slice(0, 2000)}…` : text
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createJobStore({ now = () => new Date(), keep = MAX_JOBS } = {}) {
|
|
16
|
+
const jobs = new Map()
|
|
17
|
+
|
|
18
|
+
function trim() {
|
|
19
|
+
while (jobs.size > keep) {
|
|
20
|
+
const oldest = jobs.keys().next().value
|
|
21
|
+
if (oldest === undefined) return
|
|
22
|
+
jobs.delete(oldest)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function start(kind, meta = {}) {
|
|
27
|
+
const job = {
|
|
28
|
+
id: randomUUID(),
|
|
29
|
+
kind,
|
|
30
|
+
status: 'running',
|
|
31
|
+
startedAt: nowIso(now),
|
|
32
|
+
finishedAt: null,
|
|
33
|
+
progress: { done: 0, total: 0 },
|
|
34
|
+
lines: [],
|
|
35
|
+
result: null,
|
|
36
|
+
error: null,
|
|
37
|
+
...meta,
|
|
38
|
+
}
|
|
39
|
+
jobs.set(job.id, job)
|
|
40
|
+
trim()
|
|
41
|
+
return job
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function append(jobId, level, message) {
|
|
45
|
+
const job = jobs.get(jobId)
|
|
46
|
+
if (job === undefined) return
|
|
47
|
+
job.lines.push({ at: nowIso(now), level, message: clampText(message) })
|
|
48
|
+
if (job.lines.length > MAX_LINES) job.lines.splice(0, job.lines.length - MAX_LINES)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function progress(jobId, done, total) {
|
|
52
|
+
const job = jobs.get(jobId)
|
|
53
|
+
if (job === undefined) return
|
|
54
|
+
job.progress = { done, total }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function finish(jobId, { result = null, error = null } = {}) {
|
|
58
|
+
const job = jobs.get(jobId)
|
|
59
|
+
if (job === undefined) return null
|
|
60
|
+
job.status = error === null ? 'done' : 'failed'
|
|
61
|
+
job.finishedAt = nowIso(now)
|
|
62
|
+
job.result = result
|
|
63
|
+
job.error = error === null ? null : clampText(error)
|
|
64
|
+
if (job.progress.total > 0) job.progress = { ...job.progress, done: job.progress.total }
|
|
65
|
+
return snapshot(job)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function snapshot(job) {
|
|
69
|
+
if (job === undefined || job === null) return null
|
|
70
|
+
return {
|
|
71
|
+
id: job.id,
|
|
72
|
+
kind: job.kind,
|
|
73
|
+
status: job.status,
|
|
74
|
+
startedAt: job.startedAt,
|
|
75
|
+
finishedAt: job.finishedAt,
|
|
76
|
+
progress: job.progress,
|
|
77
|
+
lines: job.lines,
|
|
78
|
+
result: job.result,
|
|
79
|
+
error: job.error,
|
|
80
|
+
...(job.previewId === undefined ? {} : { previewId: job.previewId }),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function get(jobId) {
|
|
85
|
+
return snapshot(jobs.get(jobId))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function latest(kind) {
|
|
89
|
+
let found = null
|
|
90
|
+
for (const job of jobs.values()) {
|
|
91
|
+
if (kind !== undefined && job.kind !== kind) continue
|
|
92
|
+
found = job
|
|
93
|
+
}
|
|
94
|
+
return snapshot(found)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function list(kind) {
|
|
98
|
+
const items = []
|
|
99
|
+
for (const job of jobs.values()) {
|
|
100
|
+
if (kind !== undefined && job.kind !== kind) continue
|
|
101
|
+
items.push(snapshot(job))
|
|
102
|
+
}
|
|
103
|
+
return items.slice(-keep).reverse()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { start, append, progress, finish, get, latest, list }
|
|
107
|
+
}
|