plugins: check dependencies before installing
Massimo Melina committed
Jun 6, 2023 at 14:07 UTC
fb669572b7dfc32cd889032d9bd465358f791900
7 files changed
+62
-18
admin/src/OnlinePlugins.ts
+13
-3
@@ -10,6 +10,7 @@ import { useDebounce } from 'usehooks-ts'
10
import { renderName, showError, startPlugin, UpdateButton } from './InstalledPlugins'
11
import { state, useSnapState } from './state'
12
import _ from 'lodash'
13
+import { alertDialog } from './dialog'
14
15
export default function OnlinePlugins() {
16
const [search, setSearch] = useState('')
@@ -70,7 +71,7 @@ export default function OnlinePlugins() {
71
disableColumnMenu: true,
72
hideable: false,
73
renderCell({ row }) {
73
- const { id, branch } = row
74
+ const { id } = row
75
return h('div', {},
76
row.update ? h(UpdateButton, {
77
id,
@@ -86,8 +87,17 @@ export default function OnlinePlugins() {
87
tooltipProps: { placement:'bottom-end' }, // workaround problem with horizontal scrolling by moving the tooltip leftward
88
confirm: "WARNING - Proceed only if you trust this author and this plugin",
89
async onClick() {
89
- const res = await apiCall('download_plugin', { id, branch }, { timeout: false })
90
- await startPlugin(res.id)
90
+ const branch = row.branch || row.default_branch
91
+ try {
92
+ const res = await apiCall('download_plugin', { id, branch }, { timeout: false })
93
+ await startPlugin(res.id)
94
+ }
95
+ catch(e: any) {
96
+ if (e.code !== 424) throw e
97
+ const msg = h(Fragment, {}, "This plugin has some dependencies unmet:",
98
+ e.data.map((x: any) => h('li', {}, x.repo + ': ' + x.error)) )
99
+ return alertDialog(msg, 'error')
100
+ }
101
}
102
})
103
)
dev-plugins.md
+2
@@ -43,6 +43,7 @@ All the following properties are optional unless otherwise specified.
43
- `description: string` try to explain what this plugin is for. This must go in `exports` and use "double quotes".
44
- `version: number` use progressive numbers to distinguish each release. This must go in `exports`.
45
- `apiRequired: number | [min:number,max:number]` declare version(s) for which the plugin is designed for. You'll find api version in `src/const.ts`. This must go in `exports` and is mandatory.
46
+ - `depend: { repo: string, version: number }[]` declare what other plugins this depends on. This must go in `exports`
47
- `frontend_css: string | string[]` path to one or more css files that you want the frontend to load. These are to be placed in the `public` folder (refer below).
48
You can also include external files, by entering a full URL.
49
- `frontend_js: string | string[]` path to one or more js files that you want the frontend to load. These are to be placed in the `public` folder (refer below).
@@ -296,6 +297,7 @@ HFS will scan through them in inverted alphabetical order searching for a compat
297
- platform-dependent distribution
298
- HFS.watchState, emit
299
- api.storageDir, customApiCall
300
+ - exports.depend
301
- 8.1 (v0.45.0) should have been 0.44.0 but forgot to update number
302
- full URL support for frontend_js and frontend_css
303
- custom.html
shared/api.ts
+12
-7
@@ -39,13 +39,15 @@ export function apiCall<T=any>(cmd: string, params?: Dict, options: ApiCallOptio
39
}).then(async res => {
40
stop?.()
41
let body: any = await res.text()
42
- try { body = JSON.parse(body) }
42
+ let data: any
43
+ try { data = JSON.parse(body) }
44
catch {}
44
- console.debug(res.ok ? 'API' : 'API FAILED', cmd, params, '>>', body)
45
- await options.onResponse?.(res, body)
45
+ const result = data ?? body
46
+ console.debug(res.ok ? 'API' : 'API FAILED', cmd, params, '>>', result)
47
+ await options.onResponse?.(res, result)
48
if (!res.ok)
47
- throw new ApiError(res.status, body || `Failed API ${cmd}: ${res.statusText}`)
48
- return body as T
49
+ throw new ApiError(res.status, data === undefined ? body : `Failed API ${cmd}: ${res.statusText}`, data)
50
+ return result as T
51
}, err => {
52
stop?.()
53
if (err?.message?.includes('fetch'))
@@ -59,8 +61,11 @@ export function apiCall<T=any>(cmd: string, params?: Dict, options: ApiCallOptio
61
}
62
63
export class ApiError extends Error {
62
- constructor(readonly code:number, message: string) {
63
- super(message);
64
+ constructor(readonly code:number, message: string, data?: any) {
65
+ super(message, { cause: data });
66
+ }
67
+ get data() {
68
+ return this.cause
69
}
70
}
71
src/api.plugins.ts
+21
-3
@@ -22,8 +22,8 @@ import { Callback, newObj, onOff, waitFor } from './misc'
22
import { ApiError, ApiHandlers, SendListReadable } from './apiMiddleware'
23
import events from './events'
24
import { rm } from 'fs/promises'
25
-import { downloadPlugin, getFolder2repo, getRepoInfo, readOnlinePlugin, searchPlugins } from './github'
26
-import { HTTP_NOT_FOUND, HTTP_SERVER_ERROR } from './const'
25
+import { downloadPlugin, getFolder2repo, readOnlinePlugin, searchPlugins } from './github'
26
+import { HTTP_FAILED_DEPENDENCY, HTTP_NOT_FOUND, HTTP_SERVER_ERROR } from './const'
27
28
const apis: ApiHandlers = {
29
@@ -51,7 +51,8 @@ const apis: ApiHandlers = {
51
for (const [folder, repo] of Object.entries(getFolder2repo()))
52
try {
53
if (!repo) continue
54
- const online = await readOnlinePlugin(await getRepoInfo(repo))
54
+ //TODO shouldn't we consider other branches here?
55
+ const online = await readOnlinePlugin(repo)
56
if (!online.apiRequired || online.badApi) continue
57
const disk = getPluginInfo(folder)
58
if (online.version! > disk.version)
@@ -145,6 +146,7 @@ const apis: ApiHandlers = {
146
},
147
148
async download_plugin(pl) {
149
+ await checkDependencies(pl.id, pl.branch)
150
const res = await downloadPlugin(pl.id, pl.branch)
151
if (typeof res !== 'string')
152
return res
@@ -153,6 +155,7 @@ const apis: ApiHandlers = {
155
},
156
157
async update_plugin(pl) {
158
+ await checkDependencies(pl.id, pl.branch)
159
const found = findPluginByRepo(pl.id) // github id !== local id
160
if (!found)
161
return new ApiError(HTTP_NOT_FOUND)
@@ -173,3 +176,18 @@ const apis: ApiHandlers = {
176
}
177
178
export default apis
179
+
180
+async function checkDependencies(repo: string, branch: string) {
181
+ const rec = await readOnlinePlugin(repo, branch)
182
+ const miss = rec.depend && rec.depend.map((dep: any) => {
183
+ const res = findPluginByRepo(dep.repo)
184
+ const error = !res ? 'missing'
185
+ : (res.version || 0) < dep.version ? 'version'
186
+ : !isPluginEnabled(res.id) ? 'disabled'
187
+ : !isPluginRunning(res.id) ? 'stopped'
188
+ : ''
189
+ return error && { repo: dep.repo, error, id: res?.id }
190
+ }).filter(Boolean)
191
+ if (miss?.length)
192
+ throw new ApiError(HTTP_FAILED_DEPENDENCY, miss)
193
+}
\ No newline at end of file
src/const.ts
+1
@@ -42,6 +42,7 @@ export const HTTP_CONFLICT = 409
42
export const HTTP_PAYLOAD_TOO_LARGE = 413
43
export const HTTP_RANGE_NOT_SATISFIABLE = 416
44
export const HTTP_FOOL = 418
45
+export const HTTP_FAILED_DEPENDENCY = 424
46
export const HTTP_SERVER_ERROR = 500
47
48
export const IS_WINDOWS = process.platform === 'win32'
src/github.ts
+6
-5
@@ -82,9 +82,10 @@ export function readGithubFile(uri: string) {
82
.then(res => res.body)
83
}
84
85
-export async function readOnlinePlugin(repoInfo: { full_name: string, default_branch: string }, branch='') {
86
- const res = await readGithubFile(`${repoInfo.full_name}/${branch || repoInfo.default_branch}/${DIST_ROOT}/plugin.js`)
87
- const pl = parsePluginSource(repoInfo.full_name, res) // use 'repo' as 'id' client-side
85
+export async function readOnlinePlugin(repo: string, branch='') {
86
+ branch ||= (await getRepoInfo(repo)).default_branch
87
+ const res = await readGithubFile(`${repo}/${branch}/${DIST_ROOT}/plugin.js`)
88
+ const pl = parsePluginSource(repo, res) // use 'repo' as 'id' client-side
89
pl.branch = branch || undefined
90
return pl
91
}
@@ -120,7 +121,7 @@ export async function* searchPlugins(text='') {
121
for (const it of res.items) {
122
const repo = it.full_name
123
if (projectInfo?.plugins_blacklist?.includes(repo)) continue
123
- let pl = await readOnlinePlugin(it)
124
+ let pl = await readOnlinePlugin(repo, it.default_branch)
125
if (!pl.apiRequired) continue // mandatory field
126
if (pl.badApi) { // we try other branches (starting with 'api')
127
const res = await apiGithub('repos/' + it.full_name + '/branches')
@@ -140,7 +141,7 @@ export async function* searchPlugins(text='') {
141
Object.assign(pl, { // inject some extra useful fields
142
downloading: downloading[repo],
143
license: it.license?.spdx_id,
143
- }, _.pick(it, ['pushed_at', 'stargazers_count']))
144
+ }, _.pick(it, ['pushed_at', 'stargazers_count', 'default_branch']))
145
yield pl
146
}
147
}
src/plugins.ts
+7
@@ -169,6 +169,9 @@ export class Plugin {
169
}
170
}
171
}
172
+ get version(): undefined | number {
173
+ return this.data?.version
174
+ }
175
get middleware(): undefined | PluginMiddleware {
176
return this.data?.middleware
177
}
@@ -214,6 +217,7 @@ export interface AvailablePlugin {
217
version?: number
218
apiRequired?: number | [number,number]
219
repo?: string
220
+ depend?: { repo: string, version?: number }[]
221
branch?: string
222
badApi?: string
223
error?: string
@@ -422,6 +426,9 @@ export function parsePluginSource(id: string, source: string) {
426
pl.repo = /exports.repo *= *"(.*)"/.exec(source)?.[1]
427
pl.version = Number(/exports.version *= *(\d*\.?\d+)/.exec(source)?.[1]) ?? undefined
428
pl.apiRequired = tryJson(/exports.apiRequired *= *([ \d.,[\]]+)/.exec(source)?.[1]) ?? undefined
429
+ pl.depend = tryJson(/exports.depend *= *(\[.*\])/m.exec(source)?.[1])?.filter((x: any) =>
430
+ typeof x.repo === 'string' && x.version === undefined || typeof x.version === 'number'
431
+ || console.warn("plugin dependency discarded", x) )
432
if (Array.isArray(pl.apiRequired) && (pl.apiRequired.length !== 2 || !pl.apiRequired.every(_.isFinite))) // validate [from,to] form
433
pl.apiRequired = undefined
434
calculateBadApi(pl)