diff --git a/commands/package/index.js b/commands/package/index.js index c448344..5703796 100644 --- a/commands/package/index.js +++ b/commands/package/index.js @@ -1,4 +1,4 @@ -import * as install from './install.js' +import * as install from './install/index.js' import * as list from './list.js' import * as uninstall from './uninstall.js' diff --git a/commands/package/install.js b/commands/package/install.js deleted file mode 100644 index 2bb0091..0000000 --- a/commands/package/install.js +++ /dev/null @@ -1,131 +0,0 @@ -import { connect, getRestClient } from '@existdb/node-exist' - -import { readFileSync } from 'node:fs' -import { basename } from 'node:path' - -const AdminGroup = 'dba' - -async function putPackage (db, restClient, content, fileName) { - const dbPath = db.app.packageCollection + '/' + fileName - const res = await restClient.put(content, dbPath) - return { success: res.statusCode === 201, error: res.body } -} - -async function getUserInfo (db) { - const { user } = db.client.options.basic_auth - return await db.users.getUserInfo(user) -} - -function serverName (db) { - const { isSecure, options } = db.client - - const protocol = `http${isSecure ? 's' : ''}:` - const isStdPort = - (isSecure && options.port === 443) || - (!isSecure && options.port === 80) - - if (isStdPort) { - return `${protocol}//${options.host}` - } - return `${protocol}//${options.host}:${options.port}` -} - -async function removeTemporaryCollection (db) { - return await db.collections.remove(db.app.packageCollection) -} - -async function install (db, upload, localFilePath) { - const xarName = basename(localFilePath) - const contents = readFileSync(localFilePath) - - console.log(`Install ${xarName} on ${serverName(db)}`) - - const uploadResult = await upload(contents, xarName) - if (!uploadResult.success) { - throw new Error(uploadResult.error) - } - - console.log('✔︎ uploaded') - - const installResult = await db.app.install(xarName) - - if (!installResult.success) { - throw new Error(installResult.error) - } - - const installationMessage = installResult.result.update ? 'updated' : 'installed' - console.log(`✔︎ ${installationMessage}`) - - return 0 -} - -export const command = ['install ', 'i'] -export const describe = 'Install XAR packages' -const options = { - rest: { - describe: 'force upload over REST API', - type: 'boolean' - }, - xmlrpc: { - alias: 'rpc', - describe: 'force upload over XML-RPC API', - type: 'boolean' - } -} - -export const builder = yargs => { - return yargs.options(options) - .conflicts('xmlrpc', 'rest') -} - -export async function handler (argv) { - if (argv.help) { - return 0 - } - - // main - const { packages, connectionOptions, rest, xmlrpc } = argv - packages.forEach(packagePath => { - if (!packagePath.match(/\.xar$/i)) { - throw Error('Packages must have the file extension .xar! Got: "' + packagePath + '"') - } - }) - - // check permissions (and therefore implicitly the connection) - const db = connect(connectionOptions) - const accountInfo = await getUserInfo(db) - const isAdmin = accountInfo.groups.includes(AdminGroup) - if (!isAdmin) { - throw Error(`Package installation failed. User "${accountInfo.name}" is not a member of the "${AdminGroup}" group.`) - } - - let upload - if (xmlrpc) { - upload = db.app.upload - } else { - const restClient = await getRestClient(connectionOptions) - const boundUpload = putPackage.bind(null, db, restClient) - if (rest) { - upload = boundUpload - } else { - try { - await restClient.get('db') - upload = boundUpload - } catch (e) { - console.log('Falling back to XMLRPC API') - upload = db.app.upload - } - } - } - - try { - for (const i in packages) { - const packagePath = packages[i] - await install(db, upload, packagePath) - } - } finally { - await removeTemporaryCollection(db) - } - - return 0 -} diff --git a/commands/package/install/github.js b/commands/package/install/github.js new file mode 100644 index 0000000..1965253 --- /dev/null +++ b/commands/package/install/github.js @@ -0,0 +1,163 @@ +import { connect } from '@existdb/node-exist' +import { got } from 'got' +import { valid, gt } from 'semver' +// import { basename } from 'node:path' + +import { isDBAdmin, getServerUrl } from '../../../utility/connection.js' +import { uploadMethod, removeTemporaryCollection, getInstalledVersion } from '../../../utility/package.js' + +async function getRelease (api, owner, repo, release, assetFilter) { + const path = `repos/${owner}/${repo}/releases/${release}` + const { assets, name } = await got.get(path, { prefixUrl: api }).json() + const f = assets.filter(assetFilter) + if (!f.length) { + throw Error('no matching asset found') + } + if (f.length > 1) { + throw Error('more than one matching asset found') + } + return { + xarName: f[0].name, + packageContents: f[0].browser_download_url, + releaseName: name + } +} + +async function install (db, upload, xarName, contents, registry) { + // const xarName = basename(localFilePath) + // const contents = readFileSync(localFilePath) + + const uploadResult = await upload(contents, xarName) + if (!uploadResult.success) { + throw new Error(uploadResult.error) + } + + console.log('✔︎ uploaded') + + const installResult = await db.app.install(xarName, registry) + + if (!installResult.success) { + throw new Error(installResult.error) + } + + const installationMessage = installResult.result.update ? 'updated' : 'installed' + console.log(`✔︎ ${installationMessage}`) + + return 0 +} + +export const command = ['github-release ', 'gh'] +export const describe = 'Install a XAR package from a github release' +const options = { + rest: { + describe: 'force upload over REST API', + type: 'boolean' + }, + xmlrpc: { + alias: 'rpc', + describe: 'force upload over XML-RPC API', + type: 'boolean' + }, + f: { + alias: 'force', + describe: 'Force installation, skip version check' + }, + release: { + describe: 'Install a specific release', + default: 'latest', + type: 'string' + }, + owner: { + describe: 'The owner of the repository', + default: 'eXist-db', + type: 'string' + }, + repo: { + describe: 'The name of the repository, if it differs from abbrev', + type: 'string' + }, + registry: { + describe: 'Where to resolve dependencies from, if they are not already installed', + default: 'https://exist-db.org/exist/apps/public-repo/', + type: 'string' + }, + api: { + describe: 'Connect to a different github server', + default: 'https://api.github.com', + type: 'string' + }, + A: { + alias: 'asset', + describe: 'Pattern to match the package file.', + default: '-.xar' + }, + T: { + alias: 'tag-prefix', + describe: 'How to read the version from the associated git-tag', + default: 'v', + type: 'string' + }, + debug: { + type: 'boolean', + default: false + } +} + +export const builder = yargs => { + return yargs.options(options) + .conflicts('xmlrpc', 'rest') +} + +export async function handler (argv) { + if (argv.help) { + return 0 + } + + // main + const { + abbrev, api, force, T, owner, release, registry, + connectionOptions, rest, xmlrpc + } = argv + + const repo = argv.repo && argv.repo !== '' ? argv.repo : abbrev + // check permissions (and therefore implicitly the connection) + const db = connect(connectionOptions) + isDBAdmin(db) + + const upload = await uploadMethod(db, connectionOptions, xmlrpc, rest) + const tagMatcher = new RegExp(`^${T}(?.+)$`) + + // const r = false ? new RegExp(`${asset}`) : new RegExp(`^${abbrev}.*\\.xar$`) + const r = new RegExp(`^${abbrev}.*\\.xar$`) + const assetFilter = a => { return r.test(a.name) } + + try { + const installedVersion = await getInstalledVersion(db, abbrev) + const { xarName, packageContents, releaseName } = await getRelease(api, owner, repo, release, assetFilter) + const foundVersion = tagMatcher.exec(releaseName).groups.version + console.log(`Install ${abbrev} on ${getServerUrl(db)}`) + // if (debug) { + // console.debug('released:', valid(foundVersion)) + // console.debug('installed:', valid(installedVersion)) + // } + + if (valid(foundVersion) === null) { + throw Error('Package does not have a valid semver "' + foundVersion + '"') + } + + const doUpdate = (installedVersion === null || force || gt(foundVersion, installedVersion)) + + if (!doUpdate) { + console.log(`Version ${installedVersion} is already installed, nothing to do.`) + return 0 + } + + const assetDownload = await got.get(packageContents) + + await install(db, upload, xarName, assetDownload.rawBody, registry) + } finally { + await removeTemporaryCollection(db) + } + + return 0 +} diff --git a/commands/package/install/index.js b/commands/package/install/index.js new file mode 100644 index 0000000..03e9343 --- /dev/null +++ b/commands/package/install/index.js @@ -0,0 +1,31 @@ +import * as local from './local.js' +import * as github from './github.js' +// import * as registry from './registry.js' + +const commands = [ + local, + github + // registry +] + +export const command = ['install ', 'i'] +export const describe = 'Install XAR packages' +export async function handler (argv) { + if (argv.help) { + return 0 + } +} + +const options = { + registry: { + describe: 'Where to resolve dependencies from, if they are not already installed', + default: 'https://exist-db.org/exist/apps/public-repo/' + } +} + +export const builder = function (yargs) { + return yargs + .options(options) + .command(commands) + .recommendCommands() +} diff --git a/commands/package/install/local.js b/commands/package/install/local.js new file mode 100644 index 0000000..bf1f195 --- /dev/null +++ b/commands/package/install/local.js @@ -0,0 +1,85 @@ +import { connect } from '@existdb/node-exist' + +import { readFileSync } from 'node:fs' +import { basename } from 'node:path' + +import { uploadMethod, removeTemporaryCollection } from '../../../utility/package.js' +import { isDBAdmin, getServerUrl } from '../../../utility/connection.js' + +async function install (db, upload, localFilePath) { + const xarName = basename(localFilePath) + const contents = readFileSync(localFilePath) + + console.log(`Install ${xarName} on ${getServerUrl(db)}`) + + const uploadResult = await upload(contents, xarName) + if (!uploadResult.success) { + throw new Error(uploadResult.error) + } + + console.log('✔︎ uploaded') + + const installResult = await db.app.install(xarName) + + if (!installResult.success) { + throw new Error(installResult.error) + } + + const installationMessage = installResult.result.update ? 'updated' : 'installed' + console.log(`✔︎ ${installationMessage}`) + + return 0 +} + +export const command = ['local ', 'file-system', 'files'] +export const describe = 'Install XAR packages from the local filesystem' +const options = { + rest: { + describe: 'force upload over REST API', + type: 'boolean' + }, + xmlrpc: { + alias: 'rpc', + describe: 'force upload over XML-RPC API', + type: 'boolean' + }, + force: { + describe: 'force installation, skip version check' + } +} + +export const builder = yargs => { + return yargs.options(options) + .conflicts('xmlrpc', 'rest') +} + +export async function handler (argv) { + if (argv.help) { + return 0 + } + + // main + const { packages, connectionOptions, rest, xmlrpc } = argv + packages.forEach(packagePath => { + if (!packagePath.match(/\.xar$/i)) { + throw Error('Packages must have the file extension .xar! Got: "' + packagePath + '"') + } + }) + + // check permissions (and therefore implicitly the connection) + const db = connect(connectionOptions) + isDBAdmin(db) + + const upload = await uploadMethod(db, connectionOptions, xmlrpc, rest) + + try { + for (const i in packages) { + const packagePath = packages[i] + await install(db, upload, packagePath) + } + } finally { + await removeTemporaryCollection(db) + } + + return 0 +} diff --git a/commands/package/install/registry.js b/commands/package/install/registry.js new file mode 100644 index 0000000..7155310 --- /dev/null +++ b/commands/package/install/registry.js @@ -0,0 +1,90 @@ +import { connect } from '@existdb/node-exist' + +import { readFileSync } from 'node:fs' +import { basename } from 'node:path' + +import { uploadMethod, removeTemporaryCollection } from '../../../utility/package.js' +import { isDBAdmin, getServerUrl } from '../../../utility/connection.js' + +async function install (db, upload, localFilePath) { + const xarName = basename(localFilePath) + const contents = readFileSync(localFilePath) + + console.log(`Install ${xarName} on ${getServerUrl(db)}`) + + const uploadResult = await upload(contents, xarName) + if (!uploadResult.success) { + throw new Error(uploadResult.error) + } + + console.log('✔︎ uploaded') + + const installResult = await db.app.install(xarName) + + if (!installResult.success) { + throw new Error(installResult.error) + } + + const installationMessage = installResult.result.update ? 'updated' : 'installed' + console.log(`✔︎ ${installationMessage}`) + + return 0 +} + +export const command = ['registry ', 'r'] +export const describe = 'Install XAR packages from a registry (AKA public-repo)' +const options = { + rest: { + describe: 'force upload over REST API', + type: 'boolean' + }, + xmlrpc: { + alias: 'rpc', + describe: 'force upload over XML-RPC API', + type: 'boolean' + }, + f: { + alias: 'force', + describe: 'force installation, skip version check' + }, + registry: { + describe: 'Registry to query the package and ', + default: 'https://exist-db.org/exist/apps/public-repo/' + } +} + +export const builder = yargs => { + return yargs.options(options) + .conflicts('xmlrpc', 'rest') +} + +export async function handler (argv) { + if (argv.help) { + return 0 + } + + // main + const { packages, connectionOptions, rest, xmlrpc } = argv + packages.forEach(packagePath => { + if (!packagePath.match(/\.xar$/i)) { + throw Error('Packages must have the file extension .xar! Got: "' + packagePath + '"') + } + }) + + // check permissions (and therefore implicitly the connection) + const db = connect(connectionOptions) + isDBAdmin(db) + + const upload = uploadMethod(db, connectionOptions, xmlrpc, rest) + + try { + for (const i in packages) { + const packagePath = packages[i] + await install(db, upload, packagePath) + } + } finally { + await removeTemporaryCollection(db) + } + + return 0 +} diff --git a/modules/get-package-version.xq b/modules/get-package-version.xq new file mode 100644 index 0000000..9141e53 --- /dev/null +++ b/modules/get-package-version.xq @@ -0,0 +1,60 @@ +xquery version "3.1"; + +declare namespace expath="http://expath.org/ns/pkg"; +declare namespace repo="http://exist-db.org/xquery/repo"; + +declare variable $name-or-abbrev as xs:string external; + +declare +function local:get-package-meta( + $name as xs:string, $resource as xs:string +) as document-node()? { + try { + repo:get-resource($name, $resource) + => util:binary-to-string() + => parse-xml() + } + catch * { + () + } +}; + +declare +function local:find-by-abbrev($abbrev as xs:string, $expath as document-node()) as xs:boolean { + $expath//@abbrev = $abbrev +}; + +declare +function local:find($name-or-abbrev) as document-node()? { + let $list := repo:list() + let $expaths := for-each($list, local:get-package-meta(?, "expath-pkg.xml")) + + return + if ($name-or-abbrev = $list) + then $expaths[index-of($name-or-abbrev, $list)] + else filter($expaths, local:find-by-abbrev($name-or-abbrev, ?)) +}; + +try { + serialize( + map { "version": local:find($name-or-abbrev)//expath:package/@version/string() }, + map { "method": "json" } + ) +} +catch * { + serialize( + map { + "error": map { + "code": $err:code, + "description": $err:description, + "value": $err:value, + "module": $err:module, + "line-number": $err:line-number, + "column-number": $err:column-number, + "additional": $err:additional, + "xquery-stack-trace": $exerr:xquery-stack-trace + } + }, + map { "method": "json" } + ) +} diff --git a/utility/account.js b/utility/account.js index 4b222b8..713554e 100644 --- a/utility/account.js +++ b/utility/account.js @@ -13,6 +13,8 @@ * @prop {{id:number, name:string, realmId: string}} defaultGroup this user's default group info */ +export const AdminGroup = 'dba' + /** * get the user account information for a specific user * @param {NodeExist} db client connection diff --git a/utility/connection.js b/utility/connection.js index bbfe538..3ad3a4c 100644 --- a/utility/connection.js +++ b/utility/connection.js @@ -10,7 +10,7 @@ * must be set for EXISTDB_USER to take effect */ import { readOptionsFromEnv } from '@existdb/node-exist' -import { getAccountInfo } from '../utility/account.js' +import { getAccountInfo, AdminGroup } from '../utility/account.js' /** * @typedef { import("@existdb/node-exist").NodeExist } NodeExist @@ -56,3 +56,11 @@ export async function getUserInfo (db) { const { user } = db.client.options.basic_auth return await getAccountInfo(db, user) } + +export async function isDBAdmin (db) { + const accountInfo = await getUserInfo(db) + const isAdmin = accountInfo.groups.includes(AdminGroup) + if (!isAdmin) { + throw Error(`Package installation failed. User "${accountInfo.name}" is not a member of the "${AdminGroup}" group.`) + } +} diff --git a/utility/package.js b/utility/package.js new file mode 100644 index 0000000..0737e0c --- /dev/null +++ b/utility/package.js @@ -0,0 +1,43 @@ +import { getRestClient } from '@existdb/node-exist' +import { readXquery } from './xq.js' + +const queryVersion = readXquery('get-package-version.xq') + +export const expathPackageMeta = 'expath-pkg.xml' + +async function putPackage (db, restClient, content, fileName) { + const dbPath = db.app.packageCollection + '/' + fileName + const res = await restClient.put(content, dbPath) + console.log(res.body) + return { success: res.statusCode === 201, error: res.body } +} + +export async function uploadMethod (db, connectionOptions, xmlrpc, rest) { + if (xmlrpc) { + return db.app.upload + } + + const restClient = await getRestClient(connectionOptions) + const boundUpload = putPackage.bind(null, db, restClient) + if (rest) { + return boundUpload + } + + try { + await restClient.get('db') + return boundUpload + } catch (e) { + console.log('Falling back to XMLRPC API') + return db.app.upload + } +} + +export async function removeTemporaryCollection (db) { + return await db.collections.remove(db.app.packageCollection) +} + +export async function getInstalledVersion (db, nameOrAbbrev) { + const { pages } = await db.queries.readAll(queryVersion, { variables: { 'name-or-abbrev': nameOrAbbrev } }) + const rawResult = pages.toString() + return JSON.parse(rawResult).version +}