@samitouri / QOSami-HFS / commits / fb669572

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)