UNPKG

para-cli

Version:

Command-line tool for Para backend servers

950 lines (860 loc) 25.8 kB
/* * Copyright 2013-2026 Erudika. https://erudika.com * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * For issues and patches go to: https://github.com/erudika */ /* eslint-disable padding-line-between-statements */ /* eslint-disable max-params */ /* eslint-disable function-call-argument-newline */ /* eslint-disable spaced-comment */ /* eslint-disable capitalized-comments */ /* eslint complexity: ["error", 21] */ /* eslint indent: ["error", "tab"] */ /* eslint object-curly-spacing: ["error", "always"] */ /* global console, process, Buffer */ import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { basename, relative, resolve } from 'node:path'; import { URL } from 'node:url'; import { TextEncoder } from 'node:util'; import input from '@inquirer/input'; import password from '@inquirer/password'; import chalk from 'chalk'; import { globbySync } from 'globby'; import { Parser } from 'htmlparser2'; import jsonwebtoken from 'jsonwebtoken'; import { lookup } from 'mime-types'; import { Pager, ParaClient, ParaObject } from 'para-client-js'; import striptags from 'striptags'; import apiClient from 'superagent'; const encoder = new TextEncoder('utf-8'); const { cyan, red, yellow, green } = chalk; const { sign } = jsonwebtoken; const MAX_FILE_SIZE = 350 * 1024; var defaultConfig = { accessKey: '', secretKey: '', endpoint: 'https://paraio.com' }; const _defaultConfig = defaultConfig; export { _defaultConfig as defaultConfig }; export async function setup(config) { try { const accessKey = await input({ message: 'Para Access Key:', default: config.get('accessKey') || 'app:para' }); const secretKey = await password({ message: 'Para Secret Key:', mask: '*' }); const endpoint = await input({ message: 'Para Endpoint:', default: defaultConfig.endpoint }); var access = (accessKey || config.get('accessKey') || 'app:para').trim(); var secret = (secretKey || config.get('secretKey')).trim(); var endpointValue = (endpoint || defaultConfig.endpoint).trim(); newJWT(access, secret, endpointValue, config); ping(config); if (access === 'app:para') { listApps(config, {}, access, async () => { // if none, ask to create one const shouldCreate = await input({ message: 'Would you like to create a new Para app? [Y/n]', default: 'Y' }); const Yn = shouldCreate.trim(); if ('' === Yn || 'y' === Yn.toLowerCase()) { const appname = await input({ message: 'App name:' }); newApp(['', appname], config, {}); } }); } } catch (error) { if (error.name === 'ExitPromptError') { console.log('\nSetup cancelled.'); } else { throw error; } } } export function createAll(input, config, flags = {}) { if (!input[1]) { fail('No files specified.'); return; } const pc = getClient(config, flags); var files = globbySync(input[1], { realpath: true }); var totalSize = 0; var totalObjects = 0; var batches = [[]]; var batchId = 0; var batchSize = 0; for (var i = 0; i < files.length; i++) { var file = files[i]; var stats = statSync(file); var filePath = relative(flags.cwd || '.', file); var fileType = lookup(file) || 'text/plain'; var fileBody = ''; var id; if (!stats?.isFile()) { console.error(red('✖'), yellow(file), 'is not a file.'); continue; } if (fileType.match(/text\/.*/)) { totalObjects++; totalSize += stats.size; batchSize += stats.size; fileBody = readFile(file); var json = {}; if (fileType === 'text/html') { json = parseHTML(fileBody); } else { json = { text: striptags(fileBody).replace(/[\s]+/gi, ' ') }; } if (flags.sanitize) { json.text = json.text.replace(/[^0-9\p{L}]+/giu, ' ').replace(/[\s]+/gi, ' '); } id = i === 0 && flags.id ? flags.id : json.url || filePath; var textEncoded = encoder.encode(json.text); //batchSize += textEncoded.length; if (textEncoded.length > MAX_FILE_SIZE) { console.log(red('!'), yellow('File is larger than', MAX_FILE_SIZE / 1024, 'KB - splitting into chunks...')); sendFileChunk(1, textEncoded, json, id, flags, 0, MAX_FILE_SIZE, pc); } else { if (batchSize > MAX_FILE_SIZE) { batchId++; batches[batchId] = []; console.log(yellow('*'), 'Batch', yellow(batchId), 'is', Math.round(batchSize / 1024), 'KB.'); batchSize = 0; } addObjectsToBatch(batches[batchId], json, id, flags); console.log(green('✔'), 'Creating', yellow(id)); } } else if (fileType === 'application/json') { totalObjects++; id = i === 0 && flags.id ? flags.id : filePath; totalSize += stats.size; batchSize += stats.size; if (batchSize > MAX_FILE_SIZE) { batchId++; batches[batchId] = []; console.log(yellow('*'), 'Batch', yellow(batchId), 'is', Math.round(batchSize / 1024), 'KB.'); batchSize = 0; } addObjectsToBatch(batches[batchId], JSON.parse(readFile(file)), id, flags); console.log(green('✔'), 'Creating', yellow(id)); } else { console.error(red('✖'), 'Skipping', yellow(file), "- isn't JSON, HTML nor text."); } } for (var k = 0; k < batches.length; k++) { var objectsList = batches[k]; if (objectsList.length > 0) { pc.createAll(objectsList) .then((data) => { console.log(green('✔'), 'Created', data.length, 'objects.'); }) .catch((err) => { fail('Failed to create documents:', err); }); } } console.log(green('✔'), 'Created', totalObjects, 'objects with a total size of', Math.round(totalSize / 1024), 'KB.'); } export function readAll(config, flags = {}) { const pc = getClient(config, flags); if (flags.id) { var readIds = flags.id; if (!Array.isArray(readIds)) { readIds = [readIds]; } pc.readAll(readIds) .then((data) => { console.log(JSON.stringify(data, null, 2)); }) .catch((err) => { fail('Failed to read object:', err); }); } else { fail('Must specify object id(s).'); } } export function updateAll(input, config, flags = {}) { if (!input[1]) { fail('No files specified.'); return; } const pc = getClient(config, flags); var files = globbySync(input[1], { realpath: true }); var updateList = []; for (var i = 0; i < files.length; i++) { var file = files[i]; var stats = statSync(file); var fileType = lookup(file) || 'text/plain'; var defaultId = relative(flags.cwd || '.', file); if (fileType !== 'application/json') { console.error(red('✖'), yellow(file), 'skipped because it is not a JSON file'); continue; } if (!stats?.isFile()) { console.error(red('✖'), yellow(file), 'is not a file.'); continue; } var fileJSON = JSON.parse(readFile(file)); var id = fileJSON.id || defaultId; addObjectsToBatch(updateList, fileJSON, id, flags); console.log(green('✔'), 'Updating', yellow(id)); } pc.updateAll(updateList) .then(() => { console.log(green('✔'), 'Updated', updateList.length, 'files.'); }) .catch((err) => { fail('Failed to read object:', err); }); } export function deleteAll(input, config, flags = {}) { const pc = getClient(config, flags); if (flags.id || input[1]) { var deleteIds = globbySync(input[1] || ' ', { realpath: true }); if (deleteIds.length === 0) { deleteIds = Array.isArray(flags.id) ? flags.id : [String(flags.id)]; } for (var i = 0; i < deleteIds.length; i++) { deleteIds[i] = basename(String(deleteIds[i])); } pc.deleteAll(deleteIds) .then(() => { console.log(green('✔'), 'Deleted objects "', deleteIds, '" from Para.'); }) .catch((err) => { fail('Failed to delete objects:', err); }); } else { fail('No files specified.'); } } export function newKeys(config, flags = {}) { const pc = getClient(config, flags); pc.newKeys() .then((keys) => { config.set('secretKey', keys.secretKey); console.log(green('✔'), 'New JWT generated and saved in', yellow(config.path)); }) .catch((err) => { fail('Failed to generate new secret key:', err); }); } export function newJWT(accessKey, secretKey, endpoint, config, flags) { if (!accessKey || accessKey.length < 3 || !secretKey || secretKey.length < 6) { fail('Invalid credentials.'); return; } var now = Math.round(Date.now() / 1000); var sClaim = JSON.stringify({ exp: now + 7 * 24 * 60 * 60, iat: now, nbf: now - 5, // allow for 5 seconds time difference in clocks appid: accessKey }); var selectedApp = config.get('selectedApp'); if (selectedApp?.secretKey) { selectedApp.accessKey = accessKey; selectedApp.secretKey = secretKey; config.set('selectedApp', selectedApp); } else { config.set('accessKey', accessKey); config.set('secretKey', secretKey); } config.set('endpoint', endpoint || config.get('endpoint')); config.set('jwt', sign(sClaim, secretKey, { algorithm: 'HS256' })); if (flags?.print) { console.log(yellow(config.get('jwt'))); } else { console.log(green('✔'), 'New JWT generated and saved in', yellow(config.path)); } } export function newApp(input, config, flags = {}) { if (!input[1]) { fail('App name not specified.'); return; } const pc = getClient(config, flags); var appid = input[1]; var req = pc.invokeGet(`_setup/${appid}`, { name: flags.name || appid, shared: flags.shared || false }); pc.getEntity(req) .then((resp) => { if (resp?.secretKey) { console.log(green('✔'), 'App created:'); console.log(JSON.stringify(resp, null, 2)); } else { console.log(green('✔'), yellow(`App "${appid}" already exists.`)); } }) .catch((err) => { fail('Failed to create app:', err); }); } export async function deleteApp(inputArgs, config, flags = {}) { if (!inputArgs[1]) { fail('App id not specified.'); return; } const pc = getClient(config, flags); var appid = inputArgs[1]; if (appid.indexOf('app:') < 0) { appid = `app:${appid}`; } try { const confirm = await input({ message: `${red.bold(`Are you sure you want to delete ${appid}? ALL DATA FOR THAT APP WILL BE LOST! `)}yes/No` }); if (confirm === 'yes') { pc.invokeDelete(`apps/${appid}`, {}) .then((resp) => { if (resp?.ok) { console.log(green('✔'), `App ${red.bold(appid)} was deleted!`); } else { console.log(green('✔'), yellow(`App "${appid}" could not be deleted.`)); } }) .catch((err) => { fail('Failed to delete app:', err); }); } } catch (error) { if (error.name === 'ExitPromptError') { console.log('\nDelete cancelled.'); } else { throw error; } } } export function ping(config, flags = {}) { const pc = getClient(config, flags); pc.me() .then((mee) => { pc.getServerVersion() .then((ver) => { console.log( green('✔'), `Connected to Para server ${cyan.bold(`v${ver}`)}`, `on ${cyan(pc.endpoint)}. Authenticated as:`, cyan(`${mee.type} ${mee.name} (${mee.id})`) ); }) .catch(() => { fail('Connection failed. Run "para-cli setup" or check the configuration file', yellow(config.path)); process.exit(1); }); }) .catch(() => { fail('Connection failed. Run "para-cli setup" or check the configuration file', yellow(config.path)); process.exit(1); }); } export function me(config, flags = {}) { const pc = getClient(config, flags); pc.me() .then((mee) => { console.log(JSON.stringify(mee, null, 2)); }) .catch(() => { fail('Connection failed. Server might be down. Check the configuration file', yellow(config.path)); }); } export function types(config, flags = {}) { const pc = getClient(config, flags); const _types = pc .getEntity(pc.invokeGet('_types')) .then((data) => { console.log(JSON.stringify(data, null, 2)); }) .catch(() => { fail('Connection failed. Server might be down. Check the configuration file', yellow(config.path)); }); } export function exportData(config, flags = {}) { const pc = getClient(config, flags); pc.invokeGet('/_export') .then((data) => { try { var filename = data.headers['content-disposition'] || 'export.zip'; var filesize = Math.round(((data.headers['content-length'] || 0) / 1000000) * 100) / 100; filename = filename.substring(filename.lastIndexOf('=') + 1); writeFileSync(filename, data.body); console.log(green('✔'), yellow(`Exported ${filesize}MB of data to file ${filename}`)); } catch (e) { console.error(e); } }) .catch(() => { fail('Connection failed. Server might be down. Check the configuration file', yellow(config.path)); }); } export function importData(input, config, flags = {}) { if (!input[1]) { fail('No file to import.'); return; } const pc = getClient(config, flags); if (!config.get('jwt')) { newJWT(config.get('accessKey'), config.get('secretKey'), config.get('endpoint'), config); } var headers = { 'User-Agent': 'Para CLI tool', 'Content-Type': 'application/zip', Authorization: `Bearer ${config.get('jwt')}` }; try { apiClient .put(`${pc.endpoint}/v1/_import`) .set(headers) .send(readFileSync(resolve(input[1]))) .then((res) => { console.log(green('✔'), `${yellow(`Imported ${res.body.count} object into app "${res.body.appid}`)}"`); }) .catch((e) => { fail(`Import request failed. ${e}`); }); } catch (e) { fail(`Import request failed: ${e}`); } } function promiseWhile(results, fn) { return new Promise((resolve, _reject) => { function loop() { return Promise.resolve(fn()).then((result) => { if (result && result.length > 0) { result.forEach((res) => { results.push(res); }); return loop(); } resolve(); }); } loop(); }); } export function search(input, config, flags = {}) { const pc = getClient(config, flags); var p = new Pager(flags.page, flags.sort, flags.desc, flags.limit); if (flags.lastKey) { p.lastKey = flags.lastKey; } if (flags.page && flags.page === 'all') { var results = []; p.sortby = '_docid'; p.page = 1; promiseWhile(results, () => pc.findQuery(getType(flags.type), String(input[1]) || '', p)) .then(() => { console.log(JSON.stringify(results, null, 2)); }) .catch((err) => { fail('Search failed.', err); }); } else { pc.findQuery(getType(flags.type), String(input[1]) || '', p) .then((resp) => { console.log(JSON.stringify(resp, null, 2)); }) .catch((err) => { fail('Search failed.', err); }); } } export function appSettings(config, flags = {}) { const pc = getClient(config, flags); pc.appSettings() .then((settings) => { console.log(JSON.stringify(settings, null, 2)); }) .catch(() => { fail('Connection failed. Check the configuration file', yellow(config.path)); }); } export function rebuildIndex(config, flags = {}) { const pc = getClient(config, flags); pc.rebuildIndex(flags.destinationIndex) .then((response) => { console.log(JSON.stringify(response, null, 2)); }) .catch((err) => { fail('Reindex failed.', err); }); } export function listApps(config, flags, parentAccessKey, failureCallback) { var pc = getClient(config, flags); var selectedEndpoint = getSelectedEndpoint(config, flags); var p = new Pager(); var results = []; p.sortby = '_docid'; p.page = 1; promiseWhile(results, () => pc.findQuery('app', '*', p)) .then(() => { var apps = results.map((app) => app.appIdentifier.trim()); if (apps.length) { console.log( 'Found', p.count, `apps on ${cyan(selectedEndpoint.endpoint)}:\n`, yellow('[') + green(apps.join(yellow('] ['))) + yellow(']') ); console.log( '\nTyping', cyan('para-cli select'), green(apps[0]), 'will switch to that app. \nCurrent app:', green(parentAccessKey) ); process.exit(0); } else { failureCallback(); } }) .catch((_err) => { failureCallback(); }); } export function selectApp(input, config, flags) { var selectedEndpoint = getSelectedEndpoint(config, flags); var accessKey = selectedEndpoint.accessKey; var secretKey = selectedEndpoint.secretKey; if (accessKey === 'app:para' && secretKey) { var selectedApp = `app:${(input[1] || 'para').trim()}`; if (selectedApp === 'app:para') { config.set('selectedApp', selectedEndpoint); console.log(green('✔'), 'Selected', green(selectedApp), 'as the current app.'); return; } var now = Math.round(Date.now() / 1000); var jwt = sign( JSON.stringify({ iat: now, exp: now + 10, nbf: now - 5, // allow for 5 seconds time difference in clocks appid: accessKey, getCredentials: selectedApp }), secretKey, { algorithm: 'HS256' } ); var paraClient = getClient(config, flags); paraClient.setAccessToken(jwt); paraClient .me(jwt) .then((data) => { if (data?.credentials) { config.set('selectedApp', data.credentials); console.log(green('✔'), 'Selected', green(selectedApp), 'as the current app.'); } else { fail(`That did not work -${red(input[1])} try updating Para to the latest version.`); } }) .catch((_err) => { fail(`App ${red(input[1])} not found!`); }); } else { fail('This command only works when Para CLI is configured to use the keys for the root app.'); } } export function listEndpoints(config, flags, failureCallback) { var accessKey = flags.accessKey || process.env.PARA_ACCESS_KEY || config.get('accessKey'); var secretKey = flags.secretKey || process.env.PARA_SECRET_KEY || config.get('secretKey'); var endpoint = flags.endpoint || process.env.PARA_ENDPOINT || config.get('endpoint'); var endpoints = config.get('endpoints') || []; var list = [{ endpoint: endpoint, accessKey: accessKey, secretKey: secretKey }].concat(endpoints); if (list.length === 0) { failureCallback(); return []; } for (var i = 0; i < list.length; i++) { var ep = list[i]; var selected = (config.get('selectedEndpoint') || 0) === i; var rootAppConfigured = ep.accessKey === 'app:para' && ep.secretKey.length > 10; console.log( yellow(selected ? ' ➤' : ' ', `${i + 1}. `) + cyan(ep.endpoint), rootAppConfigured ? green('✔ root app configured') : red('root app not configured') ); } return list; } export async function addEndpoint(config) { try { const endpoint = await input({ message: 'Para Endpoint:' }); if (!isValidUrl(endpoint)) { fail('Endpoint must be a valid URL.'); return; } const secretKey = await password({ message: 'Para Secret Key (for root app app:para):', mask: '*' }); var endpoints = config.get('endpoints') || []; var existing = false; for (var i = 0; i < endpoints.length; i++) { var ep = endpoints[i]; if (ep.endpoint === endpoint) { ep.secretKey = secretKey; existing = true; } } if (!existing) { endpoints.push({ accessKey: 'app:para', secretKey: secretKey, endpoint: endpoint }); } config.set('endpoints', endpoints); ping(config); } catch (error) { if (error.name === 'ExitPromptError') { console.log('\nAdd endpoint cancelled.'); } else { throw error; } } } export async function removeEndpoint(config, flags) { var list = listEndpoints(config, flags, () => { console.log('No endpoints found.'); }); try { const index = await input({ message: 'Type the number of the Para endpoint to remove:' }); var selectedEndpoint = 0; if (!Number.isNaN(index) && index <= list.length && index >= 1) { selectedEndpoint = index - 1; } var url = list[selectedEndpoint].endpoint; if (selectedEndpoint === 0) { config.set('accessKey', 'app:para'); config.set('secretKey', ''); config.set('endpoint', defaultConfig.endpoint); } else { if (selectedEndpoint === config.get('selectedEndpoint')) { config.delete('selectedEndpoint'); config.delete('selectedApp'); } list.splice(selectedEndpoint, 1); list.shift(); config.set('endpoints', list); } console.log(`Removed endpoint: ${cyan(url)}`); } catch (error) { if (error.name === 'ExitPromptError') { console.log('\nRemove endpoint cancelled.'); } else { throw error; } } } export async function selectEndpoint(config, flags) { var list = listEndpoints(config, flags, () => { console.log('No endpoints found.'); }); try { const index = await input({ message: 'Type the number of the Para endpoint to select:' }); var selectedEndpoint = 0; if (!Number.isNaN(index) && index <= list.length && index >= 1) { selectedEndpoint = index - 1; } config.delete('selectedApp'); config.set('selectedEndpoint', selectedEndpoint); console.log(`Selected endpoint: ${cyan(list[selectedEndpoint].endpoint)}`); } catch (error) { if (error.name === 'ExitPromptError') { console.log('\nSelect endpoint cancelled.'); } else { throw error; } } } export function parseEndpoint(endpoint) { try { var url = new URL(endpoint); if (url.pathname !== '/') { var x = { endpoint: `${url.protocol}//${url.host}`, apiPath: `${url.pathname.replace(/\/*$/, '')}/v1/` }; return x; } } catch (e) { fail(`Invalid Para endpoint: ${endpoint}`, e); } return { endpoint: endpoint }; } function getSelectedEndpoint(config, flags) { var accessKey = flags.accessKey || process.env.PARA_ACCESS_KEY || config.get('accessKey'); var secretKey = flags.secretKey || process.env.PARA_SECRET_KEY || config.get('secretKey'); var endpoint = flags.endpoint || process.env.PARA_ENDPOINT || config.get('endpoint'); var endpoints = [{ endpoint: endpoint, accessKey: accessKey, secretKey: secretKey }].concat( config.get('endpoints') || [] ); try { return endpoints[config.get('selectedEndpoint') || 0]; } catch (_e) { config.delete('selectedEndpoint'); return endpoints[0]; } } function getClient(config, flags = {}) { var selectedEndpoint = getSelectedEndpoint(config, flags); return new ParaClient( selectedEndpoint.accessKey, selectedEndpoint.secretKey, parseEndpoint(selectedEndpoint.endpoint) ); } function sendFileChunk(chunkId, textEncoded, json, id, flags, start, end, pc, decoder) { if (start > 0 && textEncoded[start] !== 32) { for (var i = 0; i < 100 && start - i >= 0; i++) { if (textEncoded[start - i] === 32) { start = start - i + 1; break; } } } if (end >= textEncoded.length) { end = textEncoded.length; } if (textEncoded[end] !== 32) { for (var j = 0; j < 100 && end - j >= 0; j++) { if (textEncoded[end - j] === 32) { end -= j; break; } } } if (typeof decoder === 'undefined') { decoder = new TextDecoder(); } var chunk = textEncoded.slice(start, end); var text = decoder.decode(chunk); var obj = getParaObject(Object.assign({}, json, { text: text }), `${id}_chunk${chunkId}`, flags); if (text && text.trim().length > 0) { obj.chunkid = chunkId; pc.create(obj) .then(() => { console.log( green('✔'), 'Created object chunk', yellow(chunkId), 'with size', Math.round(chunk.length / 1024), 'KB.' ); if (end < textEncoded.length) { sendFileChunk( ++chunkId, textEncoded, json, id, flags, start + MAX_FILE_SIZE, end + MAX_FILE_SIZE, pc, decoder ); } }) .catch((err) => { fail('Failed to create chunk:', err); }); } } function addObjectsToBatch(list, json, id, flags) { var objects = Array.isArray(json) ? json : [json]; for (var i = 0; i < objects.length; i++) { list.push(getParaObject(objects[i], id, flags)); } return objects; } function getParaObject(json, id, flags) { var pobj = new ParaObject(); if (flags?.type) { pobj.setType(getType(flags.type)); } id = String(id); if (flags && flags.encodeId === 'false') { pobj.setId(id); } else { pobj.setId(Buffer.from(id || '').toString('base64')); } pobj.setName(id); pobj.setFields(json); return pobj; } function getType(type) { if (type && type.trim().length > 0) { return type.replace(/[^\w\s]/giu, ' ').replace(/[\s]+/gi, '-'); } return null; } function parseHTML(file) { var title = null; var url = null; var text = ''; var inScript = false; var inAnchor = false; var parser = new Parser( { onopentag: (tag, attribs) => { if (tag === 'meta' && attribs.property === 'og:title') { title = attribs.content; } if (tag === 'meta' && attribs.property === 'og:url') { url = attribs.content; } inScript = tag === 'script'; inAnchor = tag === 'a' && attribs.href && !attribs.href.match(/^http/i); }, ontext: (txt) => { if (!inScript && !inAnchor) { text += ` ${txt}`; } }, onclosetag: () => { inScript = false; inAnchor = false; } }, { decodeEntities: true } ); parser.write(file); parser.end(); return { name: title, url: url, text: (text || ' ').replace(/[\s]+/gi, ' ') }; } function isValidUrl(url) { try { new URL(url); return true; } catch (_err) { return false; } } function readFile(filePath) { return readFileSync(filePath, { encoding: 'utf8' }); } function fail(msg, err) { var errMsg = err?.response?.body?.message ? err.response.body.message : err || ''; var code = err?.response?.status ? `(${err.response.status} ${err.response.res.statusMessage})` : ''; console.error(red('✖'), msg || 'Forgive me, I have failed you!', red(errMsg), red(code)); process.exitCode = 1; }