UNPKG

dll-cli

Version:

A CLI tool for Digital Law Lab providing necessary DA package management functionalities for testing (i.e. pushing to Docassemble's playground) and other purposes

506 lines (505 loc) 20.7 kB
#!/usr/bin/env node import { appendFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { outputJson, pathExists, readJson, remove } from 'fs-extra/esm'; import { Listr } from 'listr2'; import { execa } from 'execa'; import chalk from 'chalk'; import ora from 'ora'; import hyperlinker from 'hyperlinker'; import { search } from 'fast-fuzzy'; import inquirer from 'inquirer'; import autoComplete from 'inquirer-autocomplete-prompt'; import { getDirectoriesRecursive, delay, isEmpty, containsWhitespace, getCurrentDirsOnce, } from './utilities.js'; inquirer.registerPrompt('autocomplete', autoComplete); const __dirname = fileURLToPath(new URL('.', import.meta.url)); const __cwd = process.cwd(); const configFileName = 'dll.config.json'; const pathToConfigFile = path.join(__cwd, 'dll_config'); const configFileFullPath = path.join(pathToConfigFile, configFileName); let configFileExists = await pathExists(configFileFullPath); let jsonProjectNames, jsonApiKeyNames, jsonApiKeys, jsonApiKeysObject; const getValuesFromConfig = async () => { try { const configFileJson = await readJson(configFileFullPath); jsonProjectNames = configFileJson.DA_playground_projects; jsonApiKeysObject = configFileJson.API_keys; jsonApiKeys = new Map(Object.entries(jsonApiKeysObject)); jsonApiKeyNames = Array.from(jsonApiKeys.keys()); } catch (error) { console.log(error); } }; if (configFileExists) { await getValuesFromConfig(); } let answers_CreateConfig; let answers_playground; let answers_moreProjectNames; if (!configFileExists) { const apiKeyQuestions = [ { name: 'apiKey', message: `What is the API key? ${chalk.grey('(see ' + hyperlinker('#docassemble-api-key', 'https://github.com/Digital-Law-Lab/Digital-Law-Lab/wiki/Setting-Up#docassemble-api-key') + ')')}`, validate(_value) { return isEmpty(_value) ? chalk.yellowBright('API key cannot be empty') : true; }, }, { name: 'apiKeyName', message: `What would you like to call this API key?`, default: 'dev_api_key', }, { name: 'apiRoot', message: `What is the API endpoint url?`, type: 'list', default: 'https://dev.dll.org.au/da/api', choices: [ 'https://dev.dll.org.au/da/api', 'https://app.dll.org.au/da/api', ], }, ]; answers_CreateConfig = await inquirer.prompt([ { name: 'wantToCreateConfigFile', message: `We couldn't locate a ${chalk.yellowBright.bold(configFileName)} file for this project, would you like to create one?`, type: 'confirm', }, ...apiKeyQuestions.map((_question) => (Object.assign(Object.assign({}, _question), { when(_answersHash) { return _answersHash.wantToCreateConfigFile; } }))), false && { name: 'configFileLocation', message: 'Where would you like to save your configuration file?', type: 'autocomplete', loop: false, suggestOnly: false, when(_answersHash) { return _answersHash.wantToCreateConfigFile; }, source: () => { return getDirectoriesRecursive(__cwd, { type: 'directory', includeCurrentDir: true, depthLimit: 3, currentDirText: `Current Directory [${__cwd}]`, }); }, }, ].filter(Boolean)); const inquirerLooper = async (looperObject) => { const _shouldLoobAnswers = await inquirer.prompt(looperObject.shouldLoopQuestion); if (looperObject.loopCondition(_shouldLoobAnswers)) { return [ await inquirer.prompt(looperObject.questions), ...(await inquirerLooper(looperObject)), ]; } else { return []; } }; if (answers_CreateConfig.wantToCreateConfigFile) { let answers_moreKeys = await inquirerLooper({ shouldLoopQuestion: { name: 'wantToAddMoreKeys', message: 'Add another key?', type: 'confirm', default: false, }, loopCondition: (_shouldLoopAnswers) => _shouldLoopAnswers.wantToAddMoreKeys, questions: apiKeyQuestions.map((_question) => { if (_question.name == 'apiKeyName') { return Object.assign(Object.assign({}, _question), { default: 'production_api_key' }); } return _question; }), }); const playgroundProjectNameQuestions = [ { name: 'projectName', message: 'What is the name of your DA playground project?', type: 'autocomplete', loop: false, suggestOnly: false, emptyText: 'Searching for options as you type', source: (_, input) => { if (isEmpty(input)) return []; return new Promise(async (resolve) => { const _currentDir = await getCurrentDirsOnce(__cwd, { type: 'directory', baseOnly: true, }); const fuzzySearchResult = await search(input, _currentDir); resolve([...fuzzySearchResult, 'Something else..']); }); }, validate(_choice) { if (_choice.value == 'Something else..') return true; if (!/^(?:[a-z]|[A-Z]|-|[0-9])+$/.test(_choice.value)) return chalk.yellowBright('Project name must only contain letters, numbers, or hyphens, without any space'); if (isEmpty(_choice.value)) return chalk.yellowBright('Project name cannot be empty'); return true; }, }, { name: 'projectName', message: 'Please type the name of your DA playground projec:', askAnswered: true, when: (_answersHash) => { return _answersHash.projectName === 'Something else..'; }, validate(_input) { if (!/^(?:[a-z]|[A-Z]|-|[0-9])+$/.test(_input)) return chalk.yellowBright('Project name must only contain letters, numbers, or hyphens, without any space'); if (isEmpty(_input)) return chalk.yellowBright('Project name cannot be empty'); return true; }, }, ]; answers_playground = await inquirer.prompt([ ...playgroundProjectNameQuestions, ]); answers_moreProjectNames = await inquirerLooper({ shouldLoopQuestion: { name: 'wantToAddMoreNames', message: 'Add another playground project name?', type: 'confirm', default: false, }, loopCondition: (_shouldLoopQuestion) => _shouldLoopQuestion.wantToAddMoreNames, questions: [], }); console.log(`Your configuration file will be saved at \`${path.join('.', 'dll-config', 'dll.config.json')}\``); await delay(400); const spinner = ora(`${chalk.blue('CREATING')} dll.config.json`).start(); let constructedConfig = { API_keys: {}, DA_playground_projects: [answers_playground.projectName], }; constructedConfig.API_keys[answers_CreateConfig.apiKeyName] = { api_key: answers_CreateConfig.apiKey, api_root: answers_CreateConfig.apiRoot, }; if (answers_moreKeys.length > 0) { answers_moreKeys.forEach((_answersHash) => { constructedConfig.API_keys[_answersHash.apiKeyName] = { api_key: _answersHash.apiKey, api_root: _answersHash.apiRoot, }; }); } if (answers_moreProjectNames.length > 0) { answers_moreProjectNames.forEach((_answersHash) => { constructedConfig.DA_playground_projects.push(_answersHash.projectName); }); } try { await outputJson(configFileFullPath, constructedConfig, { spaces: 2 }); await appendFile(path.join(__cwd, '.gitignore'), '\n# Digital Law Lab Config\ndll_config/**'); spinner.succeed(`${chalk.blue('CREATED')} dll.config.json successfully`); configFileExists = true; getValuesFromConfig(); } catch (error) { spinner.fail(chalk.redBright('FAILED') + ' to create config file'); console.error(error); process.exit(1); } await delay(400); } } let questions_PushToDA = [ configFileExists && { name: 'playgroundProject', message: 'Which playground project do you want to push to?', type: 'autocomplete', loop: false, suggestOnly: false, source: () => Promise.resolve([...jsonProjectNames, 'A new one..']), }, { name: 'playgroundProject', message: 'What is the name of your DA playground project?', type: 'autocomplete', when: (_answersHash) => { return configFileExists ? _answersHash.playgroundProject == 'A new one..' : !(answers_CreateConfig === null || answers_CreateConfig === void 0 ? void 0 : answers_CreateConfig.wantToCreateConfigFile); }, loop: false, suggestOnly: false, askAnswered: true, emptyText: 'Searching for options as you type', source: (_, input) => { if (isEmpty(input)) return []; return new Promise(async (resolve) => { const _currentDir = await getCurrentDirsOnce(__cwd, { type: 'directory', baseOnly: true, }); const fuzzySearchResult = await search(input, _currentDir); resolve([...fuzzySearchResult, 'Something else..']); }); }, validate(_choice) { if (_choice.value == 'Something else..') return true; if (!/^(?:[a-z]|[A-Z]|-|[0-9])+$/.test(_choice.value)) return chalk.yellowBright('Project name must only contain letters, numbers, or hyphens, without any space'); if (isEmpty(_choice.value)) return chalk.yellowBright('Project name cannot be empty'); return true; }, }, { name: 'playgroundProject', message: 'Please type the name of your DA playground projec:', askAnswered: true, when: (_answersHash) => { return _answersHash.playgroundProject === 'Something else..'; }, validate(_input) { if (!/^(?:[a-z]|[A-Z]|-|[0-9])+$/.test(_input)) return chalk.yellowBright('Project name must only contain letters, numbers, or hyphens, without any space'); if (isEmpty(_input)) return chalk.yellowBright('Project name cannot be empty'); return true; }, }, configFileExists && { name: 'apiKeyName', message: `You have more than one API key in your config file, which one would you like to use?`, type: 'list', loop: false, choices: () => [...jsonApiKeyNames, 'A new key..'], when() { return jsonApiKeyNames.length > 1; }, }, configFileExists && { name: 'apiKeyNameConfirm', message: `Do you want to use ${chalk.yellow(jsonApiKeyNames[0])} as your API key`, type: 'confirm', when() { return jsonApiKeyNames.length == 1; }, }, { name: 'apiKey', message: `What is the API key? (not the name)`, when(_answersHash) { let userWantsToAddNewKey = (typeof _answersHash.apiKeyNameConfirm !== 'undefined' && !_answersHash.apiKeyNameConfirm) || _answersHash.apiKeyName == 'A new key..'; return configFileExists ? userWantsToAddNewKey : true; }, validate(_value) { if (isEmpty(_value)) return chalk.yellowBright('API key cannot be empty'); if (containsWhitespace(_value)) return chalk.yellowBright('API key must not contain any whitespace character'); return true; }, }, { name: 'apiRoot', message: 'What is the API root url?', type: 'list', default: 'https://dev.dll.org.au/da/api', choices: ['https://dev.dll.org.au/da/api', 'https://app.dll.org.au/da/api'], when(_answersHash) { let userWantsToAddNewKey = (typeof _answersHash.apiKeyNameConfirm !== 'undefined' && !_answersHash.apiKeyNameConfirm) || _answersHash.apiKeyName == 'A new key..'; return configFileExists ? userWantsToAddNewKey : true; }, }, { name: 'folderPath', message: 'Which folder do you want to push to the playground?', type: 'autocomplete', loop: false, suggestOnly: false, source: () => { return getDirectoriesRecursive(__cwd, { includeCurrentDir: true, currentDirText: `Current folder [${__cwd}]`, }); }, filter(input) { if (input.includes('Current folder')) return String(input.split(/\[|\]/)[1]); return path.join(__cwd, input); }, }, ].filter(Boolean); const answers_PushToDA = await inquirer.prompt(questions_PushToDA); if (answers_PushToDA.apiKey) { } const userAddedNewKey = configFileExists ? answers_PushToDA.apiKeyName === 'A new key..' || (typeof answers_PushToDA.apiKeyNameConfirm !== 'undefined' && !answers_PushToDA.apiKeyNameConfirm) : true; if (userAddedNewKey) { answers_PushToDA.apiKeyName = 'dll_api_key'; } if (!userAddedNewKey && configFileExists) { if (answers_PushToDA.apiKeyNameConfirm) { answers_PushToDA.apiKeyName = jsonApiKeyNames[0]; } answers_PushToDA.apiKey = jsonApiKeys.get(answers_PushToDA.apiKeyName).api_key; answers_PushToDA.apiRoot = jsonApiKeys.get(answers_PushToDA.apiKeyName).api_root; } const getPythonPath = () => process.platform != 'win32' ? 'python3' : 'python'; const checkingPythonInstallationTitle = 'Checking python installation'; const checkingPythonInstallationCallback = (ctx, task) => new Promise((resolve, reject) => { execa(getPythonPath(), ['--version']) .then((log) => { task.title = chalk.greenBright(checkingPythonInstallationTitle); task.output = `${log.stdout} was found`; resolve('Python was found'); }) .catch((err) => { task.title = chalk.redBright(checkingPythonInstallationTitle); task.output = "Couldn't run python, make sure it is installed and accessible!"; reject(new Error(err.message)); }); }); const tempSecretsJsonPath = path.join(__cwd, 'dll_config', 'secrets.json'); const createTempSecretsJsonFileTitle = 'Creating a temporary `secrets.json`'; const createTempSecretsJsonFileCallback = (ctx, task) => new Promise((resolve, reject) => { let tempApiKeysObj = {}; tempApiKeysObj[answers_PushToDA.apiKeyName] = { api_key: answers_PushToDA.apiKey, api_root: answers_PushToDA.apiRoot, }; outputJson(tempSecretsJsonPath, tempApiKeysObj, { spaces: 2 }) .then(() => { task.title = chalk.greenBright(createTempSecretsJsonFileTitle); task.output = `File created successfully`; resolve('Created successfully'); }) .catch((error) => { reject(new Error(error)); }); }); const deleteTempSecretsJsonFileTitle = 'Cleaning up temporary residules'; const deleteTempSecretsJsonFileCallback = (_, task) => new Promise((resolve, reject) => { remove(tempSecretsJsonPath) .then(() => { task.title = chalk.greenBright(deleteTempSecretsJsonFileTitle); task.output = ''; resolve('File deleted successfully'); }) .catch((error) => reject(error)); }); const pythonPlaygroundManagerPath = path.join(__dirname, 'python-scripts', 'docassemble_playground_manager.py'); const runPythonPlaygroundManagerScriptTitle = 'Running `docassemble_playground_manager.py`'; const runPythonPlaygroundManagerScriptCallback = (ctx, task) => new Promise((resolve, reject) => { execa(getPythonPath(), [ pythonPlaygroundManagerPath, '--secrets_file', tempSecretsJsonPath, '--secret', answers_PushToDA.apiKeyName, '--push', '--project', answers_PushToDA.playgroundProject, '--package', answers_PushToDA.folderPath, ]) .then((log) => { if (!isEmpty(log.stderr)) { if (log.stderr.includes('DEBUG')) { task.title = chalk.redBright(runPythonPlaygroundManagerScriptTitle + '[DEBUG]'); ctx.debugEncountered = true; ctx.debugMsg = log.stderr; } if (log.stderr.includes('ERROR')) { task.title = chalk.redBright(runPythonPlaygroundManagerScriptTitle + '[ERROR]'); reject(log); } if (log.stderr.includes('WARNING')) { ctx.warningEncountered = true; ctx.warningMsg = log.stderr; task.title = chalk.yellowBright(runPythonPlaygroundManagerScriptTitle + '[WARNING]'); } else { task.output = log.stderr; } } else { task.output = log.stdout; } ctx.pythonScriptDoneWithoutExpectedErr = true; resolve('Done'); }) .catch((err) => { task.title = chalk.redBright('Running `docassemble_playground_manager.py`'); task.output = 'Failed to run the script'; reject(err.message); }); }); const pythonTasks = new Listr([ { title: checkingPythonInstallationTitle, task: checkingPythonInstallationCallback, options: { persistentOutput: true }, }, { title: createTempSecretsJsonFileTitle, task: createTempSecretsJsonFileCallback, options: { persistentOutput: true }, }, { title: runPythonPlaygroundManagerScriptTitle, task: runPythonPlaygroundManagerScriptCallback, options: { persistentOutput: true, bottomBar: Infinity }, }, { title: deleteTempSecretsJsonFileTitle, enabled: (ctx) => !!ctx.pythonScriptDoneWithoutExpectedErr, task: deleteTempSecretsJsonFileCallback, options: { persistentOutput: true }, }, ], { concurrent: false, rendererOptions: { showErrorMessage: false } }); try { const pythonTasksResponsCtx = await pythonTasks.run(); if (pythonTasksResponsCtx.warningEncountered || pythonTasksResponsCtx.debugEncountered) { console.group(); console.group(); if (pythonTasksResponsCtx.debugEncountered) { console.log(pythonTasksResponsCtx.debugMsg); } else { console.warn(pythonTasksResponsCtx.warningMsg); } console.groupEnd(); console.groupEnd(); } } catch (error) { console.group(); console.group(); console.error(error); console.groupEnd(); console.groupEnd(); process.exit(1); }