diff --git a/src/SDK/Language/CLI.php b/src/SDK/Language/CLI.php index adc4ec214..4974c7cde 100644 --- a/src/SDK/Language/CLI.php +++ b/src/SDK/Language/CLI.php @@ -132,6 +132,11 @@ public function getFiles(): array 'destination' => 'lib/validations.js', 'template' => 'cli/lib/validations.js.twig', ], + [ + 'scope' => 'default', + 'destination' => 'lib/spinner.js', + 'template' => 'cli/lib/spinner.js.twig', + ], [ 'scope' => 'default', 'destination' => 'lib/parser.js', diff --git a/templates/cli/base/params.twig b/templates/cli/base/params.twig index e36ae2f9c..87ca01341 100644 --- a/templates/cli/base/params.twig +++ b/templates/cli/base/params.twig @@ -11,19 +11,17 @@ if (!fs.lstatSync(folderPath).isDirectory()) { throw new Error('The path is not a directory.'); } - + const ignorer = ignore(); const func = localConfig.getFunction(functionId); if (func.ignore) { ignorer.add(func.ignore); - log('Ignoring files using configuration from appwrite.json'); } else if (fs.existsSync(pathLib.join({{ parameter.name | caseCamel | escapeKeyword }}, '.gitignore'))) { ignorer.add(fs.readFileSync(pathLib.join({{ parameter.name | caseCamel | escapeKeyword }}, '.gitignore')).toString()); - log('Ignoring files in .gitignore'); } - + const files = getAllFiles({{ parameter.name | caseCamel | escapeKeyword }}).map((file) => pathLib.relative({{ parameter.name | caseCamel | escapeKeyword }}, file)).filter((file) => !ignorer.ignores(file)); await tar @@ -81,4 +79,4 @@ payload['key'] = globalConfig.getKey(); const queryParams = new URLSearchParams(payload); apiPath = `${globalConfig.getEndpoint()}${apiPath}?${queryParams.toString()}`; -{% endif %} \ No newline at end of file +{% endif %} diff --git a/templates/cli/base/requests/file.twig b/templates/cli/base/requests/file.twig index 6765eed3c..64b07d891 100644 --- a/templates/cli/base/requests/file.twig +++ b/templates/cli/base/requests/file.twig @@ -1,7 +1,7 @@ {% for parameter in method.parameters.all %} {% if parameter.type == 'file' %} const size = {{ parameter.name | caseCamel | escapeKeyword }}.size; - + const apiHeaders = { {% for parameter in method.parameters.header %} '{{ parameter.name }}': ${{ parameter.name | caseCamel | escapeKeyword }}, @@ -45,7 +45,7 @@ } let uploadableChunkTrimmed; - + if(currentPosition + 1 >= client.CHUNK_SIZE) { uploadableChunkTrimmed = uploadableChunk; } else { @@ -99,12 +99,12 @@ } {% if method.packaging %} - fs.unlinkSync(filePath); + await fs.unlink(filePath,()=>{}); {% endif %} {% if method.type == 'location' %} fs.writeFileSync(destination, response); {% endif %} - + if (parseOutput) { parse(response) success() @@ -112,4 +112,4 @@ return response; {% endif %} -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/templates/cli/lib/commands/push.js.twig b/templates/cli/lib/commands/push.js.twig index d15053bdb..ac1606654 100644 --- a/templates/cli/lib/commands/push.js.twig +++ b/templates/cli/lib/commands/push.js.twig @@ -1,11 +1,12 @@ const inquirer = require("inquirer"); const JSONbig = require("json-bigint")({ storeAsString: false }); const { Command } = require("commander"); -const { localConfig } = require("../config"); +const { localConfig, globalConfig } = require("../config"); +const { Spinner, SPINNER_ARC, SPINNER_DOTS } = require('../spinner'); const { paginate } = require('../paginate'); const { questionsPushBuckets, questionsPushTeams, questionsPushFunctions, questionsGetEntrypoint, questionsPushCollections, questionsConfirmPushCollections } = require("../questions"); const { actionRunner, success, log, error, commandDescriptions } = require("../parser"); -const { functionsGet, functionsCreate, functionsUpdate, functionsCreateDeployment, functionsUpdateDeployment, functionsListVariables, functionsDeleteVariable, functionsCreateVariable } = require('./functions'); +const { functionsGet, functionsCreate, functionsUpdate, functionsCreateDeployment, functionsUpdateDeployment, functionsGetDeployment, functionsListVariables, functionsDeleteVariable, functionsCreateVariable } = require('./functions'); const { databasesGet, databasesCreate, @@ -39,13 +40,14 @@ const { } = require("./teams"); const STEP_SIZE = 100; // Resources -const POOL_DEBOUNCE = 2000; // Milliseconds +const POLL_DEBOUNCE = 2000; // Milliseconds +const POLL_MAX_DEBOUNCE = 30; // Times -let poolMaxDebounces = 30; +let pollMaxDebounces = 30; const awaitPools = { wipeAttributes: async (databaseId, collectionId, iteration = 1) => { - if (iteration > poolMaxDebounces) { + if (iteration > pollMaxDebounces) { return false; } @@ -62,12 +64,12 @@ const awaitPools = { let steps = Math.max(1, Math.ceil(total / STEP_SIZE)); if (steps > 1 && iteration === 1) { - poolMaxDebounces *= steps; + pollMaxDebounces *= steps; - log('Found a large number of attributes, increasing timeout to ' + (poolMaxDebounces * POOL_DEBOUNCE / 1000 / 60) + ' minutes') + log('Found a large number of attributes, increasing timeout to ' + (pollMaxDebounces * POLL_DEBOUNCE / 1000 / 60) + ' minutes') } - await new Promise(resolve => setTimeout(resolve, POOL_DEBOUNCE)); + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); return await awaitPools.wipeAttributes( databaseId, @@ -76,7 +78,7 @@ const awaitPools = { ); }, wipeIndexes: async (databaseId, collectionId, iteration = 1) => { - if (iteration > poolMaxDebounces) { + if (iteration > pollMaxDebounces) { return false; } @@ -93,12 +95,12 @@ const awaitPools = { let steps = Math.max(1, Math.ceil(total / STEP_SIZE)); if (steps > 1 && iteration === 1) { - poolMaxDebounces *= steps; + pollMaxDebounces *= steps; - log('Found a large number of indexes, increasing timeout to ' + (poolMaxDebounces * POOL_DEBOUNCE / 1000 / 60) + ' minutes') + log('Found a large number of indexes, increasing timeout to ' + (pollMaxDebounces * POLL_DEBOUNCE / 1000 / 60) + ' minutes') } - await new Promise(resolve => setTimeout(resolve, POOL_DEBOUNCE)); + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); return await awaitPools.wipeIndexes( databaseId, @@ -107,7 +109,7 @@ const awaitPools = { ); }, wipeVariables: async (functionId, iteration = 1) => { - if (iteration > poolMaxDebounces) { + if (iteration > pollMaxDebounces) { return false; } @@ -123,12 +125,12 @@ const awaitPools = { let steps = Math.max(1, Math.ceil(total / STEP_SIZE)); if (steps > 1 && iteration === 1) { - poolMaxDebounces *= steps; + pollMaxDebounces *= steps; - log('Found a large number of variables, increasing timeout to ' + (poolMaxDebounces * POOL_DEBOUNCE / 1000 / 60) + ' minutes') + log('Found a large number of variables, increasing timeout to ' + (pollMaxDebounces * POLL_DEBOUNCE / 1000 / 60) + ' minutes') } - await new Promise(resolve => setTimeout(resolve, POOL_DEBOUNCE)); + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); return await awaitPools.wipeVariables( functionId, @@ -136,15 +138,15 @@ const awaitPools = { ); }, expectAttributes: async (databaseId, collectionId, attributeKeys, iteration = 1) => { - if (iteration > poolMaxDebounces) { + if (iteration > pollMaxDebounces) { return false; } let steps = Math.max(1, Math.ceil(attributeKeys.length / STEP_SIZE)); if (steps > 1 && iteration === 1) { - poolMaxDebounces *= steps; + pollMaxDebounces *= steps; - log('Creating a large number of attributes, increasing timeout to ' + (poolMaxDebounces * POOL_DEBOUNCE / 1000 / 60) + ' minutes') + log('Creating a large number of attributes, increasing timeout to ' + (pollMaxDebounces * POLL_DEBOUNCE / 1000 / 60) + ' minutes') } const { attributes } = await paginate(databasesListAttributes, { @@ -171,7 +173,7 @@ const awaitPools = { return true; } - await new Promise(resolve => setTimeout(resolve, POOL_DEBOUNCE)); + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); return await awaitPools.expectAttributes( databaseId, @@ -181,15 +183,15 @@ const awaitPools = { ); }, expectIndexes: async (databaseId, collectionId, indexKeys, iteration = 1) => { - if (iteration > poolMaxDebounces) { + if (iteration > pollMaxDebounces) { return false; } let steps = Math.max(1, Math.ceil(indexKeys.length / STEP_SIZE)); if (steps > 1 && iteration === 1) { - poolMaxDebounces *= steps; + pollMaxDebounces *= steps; - log('Creating a large number of indexes, increasing timeout to ' + (poolMaxDebounces * POOL_DEBOUNCE / 1000 / 60) + ' minutes') + log('Creating a large number of indexes, increasing timeout to ' + (pollMaxDebounces * POLL_DEBOUNCE / 1000 / 60) + ' minutes') } const { indexes } = await paginate(databasesListIndexes, { @@ -216,7 +218,7 @@ const awaitPools = { return true; } - await new Promise(resolve => setTimeout(resolve, POOL_DEBOUNCE)); + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); return await awaitPools.expectIndexes( databaseId, @@ -236,7 +238,7 @@ const push = new Command("push") command.help() })); -const pushFunction = async ({ functionId, all, yes } = {}) => { +const pushFunction = async ({ functionId, all, yes, async } = {}) => { let response = {}; const functionIds = []; @@ -269,19 +271,73 @@ const pushFunction = async ({ functionId, all, yes } = {}) => { return func; }); + log('Validating functions'); + // Validation is done BEFORE pushing so the deployment process can be run in async with progress update for (let func of functions) { - log(`Pushing function ${func.name} ( ${func['$id']} )`) + + if (!func.entrypoint) { + log(`Function ${func.name} does not have an endpoint`); + const answers = await inquirer.prompt(questionsGetEntrypoint) + func.entrypoint = answers.entrypoint; + localConfig.updateFunction(func['$id'], func); + } + + if (func.variables) { + func.pushVariables = yes; + + try { + const { total } = await functionsListVariables({ + functionId: func['$id'], + queries: [JSON.stringify({ method: 'limit', values: [1] })], + parseOutput: false + }); + + if (total === 0) { + func.pushVariables = true; + } else if (total > 0 && !func.pushVariables) { + log(`The function ${func.name} has remote variables setup`); + const variableAnswers = await inquirer.prompt(questionsPushFunctions[1]) + func.pushVariables = variableAnswers.override.toLowerCase() === "yes"; + } + } catch (e) { + if (e.code != 404) { + throw e.message; + } + } + } + } + + + log('All functions are validated'); + log('Pushing functions\n'); + + Spinner.start(false); + let successfullyPushed = 0; + let successfullyDeployed = 0; + const failedDeployments = []; + + await Promise.all(functions.map(async (func) => { + const ignore = func.ignore ? 'appwrite.json' : '.gitignore'; + let functionExists = false; + let deploymentCreated = false; + + const updaterRow = new Spinner({ status: '', resource: func.name, id: func['$id'], end: `Ignoring using: ${ignore}` }); + + updaterRow.update({ status: 'Getting' }).startSpinner(SPINNER_DOTS); try { response = await functionsGet({ functionId: func['$id'], parseOutput: false, }); - + functionExists = true; if (response.runtime !== func.runtime) { - throw new Error(`Runtime missmatch! (local=${func.runtime},remote=${response.runtime}) Please delete remote function or update your appwrite.json`); + updaterRow.fail({ errorMessage: `Runtime mismatch! (local=${func.runtime},remote=${response.runtime}) Please delete remote function or update your appwrite.json` }) + return; } + updaterRow.update({ status: 'Updating' }).replaceSpinner(SPINNER_ARC); + response = await functionsUpdate({ functionId: func['$id'], name: func.name, @@ -297,8 +353,19 @@ const pushFunction = async ({ functionId, all, yes } = {}) => { parseOutput: false }); } catch (e) { + if (e.code == 404) { - log(`Function ${func.name} ( ${func['$id']} ) does not exist in the project. Creating ... `); + functionExists = false; + } else { + updaterRow.fail({ errorMessage: e.message ?? 'General error occurs please try again' }); + return; + } + } + + if (!functionExists) { + updaterRow.update({ status: 'Creating' }).replaceSpinner(SPINNER_DOTS); + + try { response = await functionsCreate({ functionId: func.$id || 'unique()', name: func.name, @@ -318,36 +385,19 @@ const pushFunction = async ({ functionId, all, yes } = {}) => { localConfig.updateFunction(func['$id'], { "$id": response['$id'], }); - func["$id"] = response['$id']; - log(`Function ${func.name} created.`); - } else { - throw e; + updaterRow.update({ status: 'Created' }); + } catch (e) { + updaterRow.fail({ errorMessage: e.message ?? 'General error occurs please try again' }); + return; } } if (func.variables) { - // Delete existing variables - - const { total } = await functionsListVariables({ - functionId: func['$id'], - queries: [JSON.stringify({ method: 'limit', values: [1] })], - parseOutput: false - }); - - let pushVariables = yes; - - if (total === 0) { - pushVariables = true; - } else if (total > 0 && !yes) { - const variableAnswers = await inquirer.prompt(questionsPushFunctions[1]) - pushVariables = variableAnswers.override.toLowerCase() === "yes"; - } - - if (!pushVariables) { - log(`Skipping variables for ${func.name} ( ${func['$id']} )`); + if (!func.pushVariables) { + updaterRow.update({ end: 'Skipping variables' }); } else { - log(`Pushing variables for ${func.name} ( ${func['$id']} )`); + updaterRow.update({ end: 'Pushing variables' }); const { variables } = await paginate(functionsListVariables, { functionId: func['$id'], @@ -364,7 +414,8 @@ const pushFunction = async ({ functionId, all, yes } = {}) => { let result = await awaitPools.wipeVariables(func['$id']); if (!result) { - throw new Error("Variable deletion timed out."); + updaterRow.fail({ errorMessage: 'Variable deletion timed out' }) + return; } // Push local variables @@ -379,14 +430,8 @@ const pushFunction = async ({ functionId, all, yes } = {}) => { } } - // Create tag - if (!func.entrypoint) { - const answers = await inquirer.prompt(questionsGetEntrypoint) - func.entrypoint = answers.entrypoint; - localConfig.updateFunction(func['$id'], func); - } - try { + updaterRow.update({ status: 'Pushing' }).replaceSpinner(SPINNER_ARC); response = await functionsCreateDeployment({ functionId: func['$id'], entrypoint: func.entrypoint, @@ -396,20 +441,75 @@ const pushFunction = async ({ functionId, all, yes } = {}) => { parseOutput: false }) - success(`Pushed ${func.name} ( ${func['$id']} )`); - + updaterRow.update({ status: 'Pushed' }); + deploymentCreated = true; + successfullyPushed++; } catch (e) { switch (e.code) { case 'ENOENT': - error(`Function ${func.name} ( ${func['$id']} ) not found in the current directory. Skipping ...`); + updaterRow.fail({ errorMessage: 'Not found in the current directory. Skipping...' }) break; default: - throw e; + updaterRow.fail({ errorMessage: e.message ?? 'An unknown error occurred. Please try again.' }) } } - } - success(`Pushed ${functions.length} functions`); + if (deploymentCreated && !async) { + try { + const deploymentId = response['$id']; + updaterRow.update({ status: 'Deploying', end: 'Checking deployment status...' }) + let pollChecks = 0; + + while (true) { + if (pollChecks >= POLL_MAX_DEBOUNCE) { + updaterRow.update({ end: 'Deployment is taking too long. Please check the console for more details.' }) + break; + } + + response = await functionsGetDeployment({ + functionId: func['$id'], + deploymentId: deploymentId, + parseOutput: false + }); + + + const status = response['status']; + if (status === 'ready') { + updaterRow.update({ status: 'Deployed' }); + successfullyDeployed++; + + break; + } else if (status === 'failed') { + failedDeployments.push({ name: func['name'], $id: func['$id'], deployment: response['$id'] }); + updaterRow.fail({ errorMessage: `Failed to deploy` }); + + break; + } else { + updaterRow.update({ status: 'Deploying', end: `Current status: ${status}` }) + } + + pollChecks++; + await new Promise(resolve => setTimeout(resolve, POLL_DEBOUNCE)); + } + } catch (e) { + updaterRow.fail({ errorMessage: e.message ?? 'Unknown error occurred. Please try again' }) + } + } + + updaterRow.stopSpinner(); + })); + + Spinner.stop(); + console.log('\n'); + + failedDeployments.forEach((failed) => { + const { name, deployment, $id } = failed; + const failUrl = `${globalConfig.getEndpoint().replace('/v1', '')}/console/project-${localConfig.getProject().projectId}/functions/function-${$id}/deployment-${deployment}`; + + error(`Deployment of ${name} has failed. Check at ${failUrl} for more details\n`); + }) + + success(`Pushed ${successfullyPushed} functions with ${successfullyDeployed} successful deployments.`); } const createAttribute = async (databaseId, collectionId, attribute) => { @@ -913,6 +1013,7 @@ push .option(`--functionId `, `Function ID`) .option(`--all`, `Flag to push all functions`) .option(`--yes`, `Flag to confirm all warnings`) + .option(`--async`, `Don't wait for functions deployments status`) .action(actionRunner(pushFunction)); push diff --git a/templates/cli/lib/spinner.js.twig b/templates/cli/lib/spinner.js.twig new file mode 100644 index 000000000..9bfb81edb --- /dev/null +++ b/templates/cli/lib/spinner.js.twig @@ -0,0 +1,104 @@ +const progress = require('cli-progress'); +const chalk = require('chalk'); + +const SPINNER_ARC = 'arc'; +const SPINNER_DOTS = 'dots'; + +const spinners = { + [SPINNER_ARC]: { + "interval": 100, + "frames": ["◜", "◠", "◝", "◞", "◡", "◟"] + }, + [SPINNER_DOTS]: { + "interval": 80, + "frames": ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + } +} + +class Spinner { + static start(clearOnComplete = true, hideCursor = true) { + Spinner.updatesBar = new progress.MultiBar({ + format: this.#formatter, + hideCursor, + clearOnComplete, + stopOnComplete: true, + noTTYOutput: true + }); + } + + static stop() { + Spinner.updatesBar.stop(); + } + + static #formatter(options, params, payload) { + const status = payload.status.padEnd(12); + const middle = `${payload.resource} (${payload.id})`.padEnd(40); + + let prefix = chalk.cyan(payload.prefix ?? '⧗'); + let start = chalk.cyan(status); + let end = chalk.yellow(payload.end); + + if (status.toLowerCase().trim() === 'pushed') { + start = chalk.greenBright.bold(status); + prefix = chalk.greenBright.bold('✓'); + end = ''; + } else if (status.toLowerCase().trim() === 'deploying') { + start = chalk.cyanBright.bold(status); + } else if (status.toLowerCase().trim() === 'deployed') { + start = chalk.green.bold(status); + prefix = chalk.green.bold('✓'); + end = ''; + } else if (status.toLowerCase().trim() === 'error') { + start = chalk.red.bold(status); + prefix = chalk.red.bold('✗'); + end = chalk.red(payload.errorMessage); + } + + return Spinner.#line(prefix, start, middle, end); + } + + static #line(prefix, start, middle, end, separator = '•') { + return `${prefix} ${start} ${separator} ${middle} ${!end ? '' : separator} ${end}`; + + } + + constructor(payload, total = 100, startValue = 0) { + this.bar = Spinner.updatesBar.create(total, startValue, payload) + } + + update(payload) { + this.bar.update(payload); + return this; + } + + fail(payload) { + this.stopSpinner(); + this.update({ status: 'Error', ...payload }); + } + + startSpinner(name) { + let spinnerFrame = 1; + const spinner = spinners[name] ?? spinners['dots']; + + this.spinnerInterval = setInterval(() => { + if (spinnerFrame === spinner.frames.length) spinnerFrame = 1; + this.bar.update({ prefix: spinner.frames[spinnerFrame++] }); + }, spinner.interval); + } + + stopSpinner() { + clearInterval(this.spinnerInterval); + } + + replaceSpinner(name) { + this.stopSpinner(); + this.startSpinner(name); + } +} + + +module.exports = { + Spinner, + SPINNER_ARC, + SPINNER_DOTS +} diff --git a/templates/cli/lib/utils.js.twig b/templates/cli/lib/utils.js.twig index cb7d06ce3..289b1fa6e 100644 --- a/templates/cli/lib/utils.js.twig +++ b/templates/cli/lib/utils.js.twig @@ -16,4 +16,4 @@ function getAllFiles(folder) { module.exports = { getAllFiles -}; \ No newline at end of file +}; diff --git a/templates/cli/package.json.twig b/templates/cli/package.json.twig index 1392ff294..015e494e1 100644 --- a/templates/cli/package.json.twig +++ b/templates/cli/package.json.twig @@ -24,6 +24,7 @@ "dependencies": { "undici": "^5.28.2", "chalk": "4.1.2", + "cli-progress": "^3.12.0", "cli-table3": "^0.6.2", "commander": "^9.2.0", "form-data": "^4.0.0",