From 7ac1f2bd7b4c0aec9705ae3f55fa20f4893fcaac Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 11 May 2021 17:44:25 +0300 Subject: [PATCH] Feature: Node manager (#1146) * Move managers to managers folder * Add node manager * Fix internal node setup * Remove old bitcoin core settings page * Migrate old specter versions * Fix unit tests * Fix cypress tests * Fix cypress tests * Fixes * Fixes * Fixes * Fix * Fix * Fix Cypress * some documentation * sorting imports and logger.error for testing tor * get logs from tor in doubt Co-authored-by: Kim Neunert --- .../integration/spec_empty_specter_home.js | 8 +- cypress/integration/spec_setup_tor.js | 2 +- cypress/integration/spec_setup_wizard.js | 14 +- cypress/plugins/index.js | 1 + package-lock.json | 2074 ----------------- src/cryptoadvance/specter/internal_node.py | 148 ++ .../specter/managers/config_manager.py | 66 +- .../specter/{ => managers}/device_manager.py | 10 +- .../specter/managers/node_manager.py | 192 ++ .../specter/{ => managers}/user_manager.py | 4 +- .../specter/{ => managers}/wallet_manager.py | 12 +- src/cryptoadvance/specter/node.py | 350 +++ src/cryptoadvance/specter/persistence.py | 5 + src/cryptoadvance/specter/server.py | 1 + .../specter/server_endpoints/auth.py | 2 +- .../specter/server_endpoints/controller.py | 9 +- .../specter/server_endpoints/devices.py | 4 +- .../specter/server_endpoints/nodes.py | 334 +++ .../specter/server_endpoints/settings.py | 236 +- .../specter/server_endpoints/setup.py | 17 +- .../specter/server_endpoints/wallets.py | 2 +- src/cryptoadvance/specter/specter.py | 424 +--- .../specter/static/img/file_icon.svg | 1 - .../specter/static/img/flip-horizontal.svg | 6 + .../templates/device/new_device_manual.jinja | 2 +- .../specter/templates/includes/hwi/hwi.jinja | 5 - .../templates/includes/overlay/overlay.html | 6 +- .../components/bitcoin_core_info.jinja | 4 +- .../components/node_select_popup.jinja | 77 + .../templates/includes/sidebar/sidebar.jinja | 37 +- .../internal_node_logs.jinja} | 2 +- .../node/internal_node_settings.jinja | 45 + .../templates/node/node_settings.jinja | 219 ++ .../templates/settings/auth_settings.jinja | 2 +- .../settings/bitcoin_core_settings.jinja | 285 --- .../settings/components/settings_menu.jinja | 5 +- .../specter/templates/setup/bitcoind.jinja | 2 +- .../specter/templates/setup/node_type.jinja | 2 +- src/cryptoadvance/specter/tor_daemon.py | 16 +- src/cryptoadvance/specter/user.py | 6 +- .../specter/util/bitcoind_setup_tasks.py | 44 +- tests/conftest.py | 3 +- tests/test_device_manager.py | 4 +- tests/test_managers_config.py | 5 +- tests/test_specter.py | 32 +- tests/test_wallet_manager.py | 2 +- 46 files changed, 1673 insertions(+), 3054 deletions(-) create mode 100644 src/cryptoadvance/specter/internal_node.py rename src/cryptoadvance/specter/{ => managers}/device_manager.py (92%) create mode 100644 src/cryptoadvance/specter/managers/node_manager.py rename src/cryptoadvance/specter/{ => managers}/user_manager.py (96%) rename src/cryptoadvance/specter/{ => managers}/wallet_manager.py (98%) create mode 100644 src/cryptoadvance/specter/node.py create mode 100644 src/cryptoadvance/specter/server_endpoints/nodes.py delete mode 100644 src/cryptoadvance/specter/static/img/file_icon.svg create mode 100644 src/cryptoadvance/specter/static/img/flip-horizontal.svg create mode 100644 src/cryptoadvance/specter/templates/includes/sidebar/components/node_select_popup.jinja rename src/cryptoadvance/specter/templates/{settings/bitcoin_core_internal_logs.jinja => node/internal_node_logs.jinja} (88%) create mode 100644 src/cryptoadvance/specter/templates/node/internal_node_settings.jinja create mode 100644 src/cryptoadvance/specter/templates/node/node_settings.jinja delete mode 100644 src/cryptoadvance/specter/templates/settings/bitcoin_core_settings.jinja diff --git a/cypress/integration/spec_empty_specter_home.js b/cypress/integration/spec_empty_specter_home.js index b4f2c2371a..f191d783db 100644 --- a/cypress/integration/spec_empty_specter_home.js +++ b/cypress/integration/spec_empty_specter_home.js @@ -7,9 +7,10 @@ describe('Completely empty specter-home', () => { cy.viewport(1200,660) cy.visit('/') cy.contains('Welcome to Specter Desktop') + cy.get('#node-switch-icon').click() + cy.get('[href="/nodes/node/default/"]').first().click() + cy.contains('Bitcoin Core') cy.get('[href="/settings/"] > img').click() - cy.contains('Bitcoin JSON-RPC') - cy.get('[href="/settings/general"]').click() cy.contains('Backup and Restore') cy.get('[href="/settings/auth"]').click() cy.contains('Authentication:') @@ -29,7 +30,8 @@ describe('Completely empty specter-home', () => { it('Configures the node in Specter', () => { cy.viewport(1200,660) cy.visit('/') - cy.get('[href="/settings/"] > img').click() + cy.get('#node-switch-icon').click() + cy.get('[href="/nodes/node/default/"]').first().click() cy.get('#datadir-container').then(($datadir) => { cy.log($datadir) if (!Cypress.dom.isVisible($datadir)) { diff --git a/cypress/integration/spec_setup_tor.js b/cypress/integration/spec_setup_tor.js index 3bf22c683e..15f2bfe35a 100644 --- a/cypress/integration/spec_setup_tor.js +++ b/cypress/integration/spec_setup_tor.js @@ -10,7 +10,7 @@ describe('Setup Tor and test connection', () => { cy.wait(60000) cy.get('#tor-status-text').contains('Status: Running') - cy.get('[value="test_tor"]').click() + cy.get('[value="test_tor"]').click({ timeout: 60000 }) cy.contains('Tor requests test completed successfully!') cy.get('[value="stoptor"]').click() cy.get('#tor-status-text').contains('Status: Down') diff --git a/cypress/integration/spec_setup_wizard.js b/cypress/integration/spec_setup_wizard.js index 03b064fc1d..1d10a1d6eb 100644 --- a/cypress/integration/spec_setup_wizard.js +++ b/cypress/integration/spec_setup_wizard.js @@ -17,14 +17,12 @@ describe('Setup wizard', () => { cy.contains('Configure your node') cy.get('#quicksync-switch').click() cy.get('#setup-bitcoind-dir-button').click() - cy.wait(3000) - cy.contains('Starting up Bitcoin Core...') - cy.wait(60000) - cy.contains('Setup Completed Successfully!') + cy.contains('Setup Completed Successfully!', { timeout: 60000 }) cy.get('#finish-setup-btn').click() cy.contains('Connect Specter with Bitcoin Core node.') - cy.get('[href="/settings/"]').click() + cy.get('#active-node').click() + cy.get('#active-node-settings-btn').click() cy.contains('Built in Bitcoin Node Status: Running') cy.get('[value="stopbitcoind"]').click() cy.contains('Built in Bitcoin Node Status: Down') @@ -33,10 +31,6 @@ describe('Setup wizard', () => { cy.contains('Built in Bitcoin Node Status: Running') cy.get('[name="remove_datadir"]').click() cy.get('[value="uninstall_bitcoind"]').click() - cy.contains('Specter can help you get started with your own Bitcoin Core node by setting it all up for you.') - cy.get('#external_node_view_btn').click() - cy.get('[value="useexternal"]').click() - cy.visit('/settings/tor') cy.get('[value="starttor"]').click() @@ -45,7 +39,7 @@ describe('Setup wizard', () => { cy.get('#tor-status-text').contains('Status: Down') cy.get('[value="starttor"]').click() cy.get('#tor-status-text').contains('Status: Running') - cy.get('[value="test_tor"]').click() + cy.get('[value="test_tor"]').click({ timeout: 60000 }) cy.contains('Tor requests test completed successfully!') cy.get('[value="uninstalltor"]').click() cy.get('#setup-tor-button').click() diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js index 3a117da00e..3c3b451ecb 100644 --- a/cypress/plugins/index.js +++ b/cypress/plugins/index.js @@ -28,6 +28,7 @@ module.exports = (on, config) => { var rimraf = require("rimraf"); rimraf.sync(specter_home); fs.mkdirSync(specter_home); + fs.mkdirSync(specter_home+"/nodes"); fs.mkdirSync(specter_home+"/devices"); fs.mkdirSync(specter_home+"/wallets"); return null diff --git a/package-lock.json b/package-lock.json index 5365de29b2..f9bd814d3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3,2080 +3,6 @@ "version": "0.0.1", "lockfileVersion": 1, "requires": true, - "packages": { - "": { - "name": "specter-desktop-cypress-testing", - "version": "0.0.1", - "license": "ISC", - "dependencies": { - "cypress": "^7.1.0", - "rimraf": "^3.0.2", - "wait-on": "^5.3.0" - }, - "devDependencies": {} - }, - "node_modules/@cypress/listr-verbose-renderer": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz", - "integrity": "sha1-p3SS9LEdzHxEajSz4ochr9M8ZCo=", - "dependencies": { - "chalk": "^1.1.3", - "cli-cursor": "^1.0.2", - "date-fns": "^1.27.2", - "figures": "^1.7.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@cypress/listr-verbose-renderer/node_modules/chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "dependencies": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@cypress/listr-verbose-renderer/node_modules/supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@cypress/request": { - "version": "2.88.5", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.5.tgz", - "integrity": "sha512-TzEC1XMi1hJkywWpRfD2clreTa/Z+lOrXDCxxBTBPEcY5azdPi56A6Xw+O4tWJnaJH3iIE7G5aDXZC6JgRZLcA==", - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@cypress/xvfb": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", - "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", - "dependencies": { - "debug": "^3.1.0", - "lodash.once": "^4.1.1" - } - }, - "node_modules/@cypress/xvfb/node_modules/debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/@hapi/hoek": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.1.0.tgz", - "integrity": "sha512-i9YbZPN3QgfighY/1X1Pu118VUz2Fmmhd6b2n0/O8YVgGGfw0FbUYoA97k7FkpGJ+pLCFEDLUmAPPV4D1kpeFw==" - }, - "node_modules/@hapi/topo": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.0.0.tgz", - "integrity": "sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw==", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@samverschueren/stream-to-observable": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz", - "integrity": "sha512-c/qwwcHyafOQuVQJj0IlBjf5yYgBI7YPJ77k4fOJYesb41jio65eaJODRUmfYKhTOFBrIZ66kgvGPlNbjuoRdQ==", - "dependencies": { - "any-observable": "^0.3.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@sideway/address": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.0.tgz", - "integrity": "sha512-wAH/JYRXeIFQRsxerIuLjgUu2Xszam+O5xKeatJ4oudShOOirfmsQ1D6LL54XOU2tizpCYku+s1wmU0SYdpoSA==", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.0.tgz", - "integrity": "sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==" - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" - }, - "node_modules/@types/node": { - "version": "14.14.37", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.37.tgz", - "integrity": "sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw==" - }, - "node_modules/@types/sinonjs__fake-timers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz", - "integrity": "sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg==" - }, - "node_modules/@types/sizzle": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz", - "integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==" - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, - "node_modules/ansi-escapes": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", - "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", - "engines": { - "node": ">=4" - } - }, - "node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/any-observable": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz", - "integrity": "sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog==", - "engines": { - "node": ">=6" - } - }, - "node_modules/arch": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", - "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==" - }, - "node_modules/asn1": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", - "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/async": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", - "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" - }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", - "engines": { - "node": "*" - } - }, - "node_modules/aws4": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", - "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" - }, - "node_modules/axios": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", - "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", - "dependencies": { - "follow-redirects": "^1.10.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" - }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, - "node_modules/blob-util": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", - "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==" - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", - "engines": { - "node": "*" - } - }, - "node_modules/buffer-from": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", - "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" - }, - "node_modules/cachedir": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz", - "integrity": "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==", - "engines": { - "node": ">=6" - } - }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" - }, - "node_modules/chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/chalk/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/check-more-types": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", - "integrity": "sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA=", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/ci-info": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.1.1.tgz", - "integrity": "sha512-kdRWLBIJwdsYJWYJFtAFFYxybguqeF91qpZaggjG5Nf8QKdizFG2hjqvaTXbxFIcYbSaD74KpAXv6BSm17DHEQ==" - }, - "node_modules/cli-cursor": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", - "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", - "dependencies": { - "restore-cursor": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cli-table3": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.0.tgz", - "integrity": "sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ==", - "dependencies": { - "object-assign": "^4.1.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "colors": "^1.1.2" - } - }, - "node_modules/cli-truncate": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-0.2.1.tgz", - "integrity": "sha1-nxXPuwcFAFNpIWxiasfQWrkN1XQ=", - "dependencies": { - "slice-ansi": "0.0.4", - "string-width": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "dependencies": { - "number-is-nan": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "dependencies": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/code-point-at": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/common-tags": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz", - "integrity": "sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" - }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "engines": [ - "node >= 0.8" - ], - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cypress": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-7.1.0.tgz", - "integrity": "sha512-AptQP9fVtN/FfOv8rJ9hTGJE2XQFc8saLHT38r/EeyWhzp0q/+P/DYRTDtjGZHeLTCNznAUrT4lal8jm+ouS7Q==", - "hasInstallScript": true, - "dependencies": { - "@cypress/listr-verbose-renderer": "^0.4.1", - "@cypress/request": "^2.88.5", - "@cypress/xvfb": "^1.2.4", - "@types/node": "^14.14.31", - "@types/sinonjs__fake-timers": "^6.0.2", - "@types/sizzle": "^2.3.2", - "arch": "^2.2.0", - "blob-util": "^2.0.2", - "bluebird": "^3.7.2", - "cachedir": "^2.3.0", - "chalk": "^4.1.0", - "check-more-types": "^2.24.0", - "cli-table3": "~0.6.0", - "commander": "^5.1.0", - "common-tags": "^1.8.0", - "dayjs": "^1.10.4", - "debug": "4.3.2", - "eventemitter2": "^6.4.3", - "execa": "4.1.0", - "executable": "^4.1.1", - "extract-zip": "^1.7.0", - "fs-extra": "^9.1.0", - "getos": "^3.2.1", - "is-ci": "^3.0.0", - "is-installed-globally": "~0.4.0", - "lazy-ass": "^1.6.0", - "listr": "^0.14.3", - "lodash": "^4.17.21", - "log-symbols": "^4.0.0", - "minimist": "^1.2.5", - "ospath": "^1.2.2", - "pretty-bytes": "^5.6.0", - "ramda": "~0.27.1", - "request-progress": "^3.0.0", - "supports-color": "^8.1.1", - "tmp": "~0.2.1", - "untildify": "^4.0.0", - "url": "^0.11.0", - "yauzl": "^2.10.0" - }, - "bin": { - "cypress": "bin/cypress" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/cypress/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dependencies": { - "assert-plus": "^1.0.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/date-fns": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", - "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" - }, - "node_modules/dayjs": { - "version": "1.10.4", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz", - "integrity": "sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw==" - }, - "node_modules/debug": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", - "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, - "node_modules/elegant-spinner": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz", - "integrity": "sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/eventemitter2": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz", - "integrity": "sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ==" - }, - "node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/execa/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/executable": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", - "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", - "dependencies": { - "pify": "^2.2.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/exit-hook": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", - "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "node_modules/extract-zip": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", - "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", - "dependencies": { - "concat-stream": "^1.6.2", - "debug": "^2.6.9", - "mkdirp": "^0.5.4", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - } - }, - "node_modules/extract-zip/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/extract-zip/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" - }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "engines": [ - "node >=0.6.0" - ] - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/figures": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", - "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", - "dependencies": { - "escape-string-regexp": "^1.0.5", - "object-assign": "^4.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/follow-redirects": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.3.tgz", - "integrity": "sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", - "engines": { - "node": "*" - } - }, - "node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 0.12" - } - }, - "node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/getos": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", - "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", - "dependencies": { - "async": "^3.2.0" - } - }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "dependencies": { - "assert-plus": "^1.0.0" - } - }, - "node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - } - }, - "node_modules/global-dirs": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz", - "integrity": "sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==", - "dependencies": { - "ini": "2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", - "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==" - }, - "node_modules/har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", - "engines": { - "node": ">=4" - } - }, - "node_modules/har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "dependencies": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - }, - "engines": { - "node": ">=0.8", - "npm": ">=1.3.7" - } - }, - "node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/indent-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", - "integrity": "sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok=", - "engines": { - "node": ">=4" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "engines": { - "node": ">=10" - } - }, - "node_modules/is-ci": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.0.tgz", - "integrity": "sha512-kDXyttuLeslKAHYL/K28F2YkM3x5jvFPEw3yXbRptXydjD9rpLEz+C5K5iutY9ZiUu6AP41JdvRQwF4Iqs4ZCQ==", - "dependencies": { - "ci-info": "^3.1.1" - }, - "bin": { - "is-ci": "bin.js" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-installed-globally": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", - "dependencies": { - "global-dirs": "^3.0.0", - "is-path-inside": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-observable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-1.1.0.tgz", - "integrity": "sha512-NqCa4Sa2d+u7BWc6CukaObG3Fh+CU9bvixbpcXYhy2VvYS7vVGIdAgnIS5Ks3A/cqk4rebLJ9s8zBstT2aKnIA==", - "dependencies": { - "symbol-observable": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" - }, - "node_modules/is-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", - "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" - }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" - }, - "node_modules/joi": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.3.0.tgz", - "integrity": "sha512-Qh5gdU6niuYbUIUV5ejbsMiiFmBdw8Kcp8Buj2JntszCkCfxJ9Cz76OtHxOZMPXrt5810iDIXs+n1nNVoquHgg==", - "dependencies": { - "@hapi/hoek": "^9.0.0", - "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.0", - "@sideway/formula": "^3.0.0", - "@sideway/pinpoint": "^2.0.0" - } - }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" - }, - "node_modules/json-schema": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsprim": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", - "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "engines": [ - "node >=0.6.0" - ], - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.2.3", - "verror": "1.10.0" - } - }, - "node_modules/lazy-ass": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", - "integrity": "sha1-eZllXoZGwX8In90YfRUNMyTVRRM=", - "engines": { - "node": "> 0.8" - } - }, - "node_modules/listr": { - "version": "0.14.3", - "resolved": "https://registry.npmjs.org/listr/-/listr-0.14.3.tgz", - "integrity": "sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA==", - "dependencies": { - "@samverschueren/stream-to-observable": "^0.3.0", - "is-observable": "^1.1.0", - "is-promise": "^2.1.0", - "is-stream": "^1.1.0", - "listr-silent-renderer": "^1.1.1", - "listr-update-renderer": "^0.5.0", - "listr-verbose-renderer": "^0.5.0", - "p-map": "^2.0.0", - "rxjs": "^6.3.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/listr-silent-renderer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz", - "integrity": "sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4=", - "engines": { - "node": ">=4" - } - }, - "node_modules/listr-update-renderer": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/listr-update-renderer/-/listr-update-renderer-0.5.0.tgz", - "integrity": "sha512-tKRsZpKz8GSGqoI/+caPmfrypiaq+OQCbd+CovEC24uk1h952lVj5sC7SqyFUm+OaJ5HN/a1YLt5cit2FMNsFA==", - "dependencies": { - "chalk": "^1.1.3", - "cli-truncate": "^0.2.1", - "elegant-spinner": "^1.0.1", - "figures": "^1.7.0", - "indent-string": "^3.0.0", - "log-symbols": "^1.0.2", - "log-update": "^2.3.0", - "strip-ansi": "^3.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/listr-update-renderer/node_modules/chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", - "dependencies": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/listr-update-renderer/node_modules/log-symbols": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz", - "integrity": "sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=", - "dependencies": { - "chalk": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/listr-update-renderer/node_modules/supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/listr-verbose-renderer": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/listr-verbose-renderer/-/listr-verbose-renderer-0.5.0.tgz", - "integrity": "sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw==", - "dependencies": { - "chalk": "^2.4.1", - "cli-cursor": "^2.1.0", - "date-fns": "^1.27.2", - "figures": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/listr-verbose-renderer/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/listr-verbose-renderer/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/listr-verbose-renderer/node_modules/cli-cursor": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", - "dependencies": { - "restore-cursor": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/listr-verbose-renderer/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/listr-verbose-renderer/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" - }, - "node_modules/listr-verbose-renderer/node_modules/figures": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", - "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/listr-verbose-renderer/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "engines": { - "node": ">=4" - } - }, - "node_modules/listr-verbose-renderer/node_modules/mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", - "engines": { - "node": ">=4" - } - }, - "node_modules/listr-verbose-renderer/node_modules/onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", - "dependencies": { - "mimic-fn": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/listr-verbose-renderer/node_modules/restore-cursor": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", - "dependencies": { - "onetime": "^2.0.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/listr-verbose-renderer/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/listr/node_modules/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" - }, - "node_modules/log-symbols": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz", - "integrity": "sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==", - "dependencies": { - "chalk": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/log-update": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-2.3.0.tgz", - "integrity": "sha1-iDKP19HOeTiykoN0bwsbwSayRwg=", - "dependencies": { - "ansi-escapes": "^3.0.0", - "cli-cursor": "^2.0.0", - "wrap-ansi": "^3.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/log-update/node_modules/cli-cursor": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", - "dependencies": { - "restore-cursor": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/log-update/node_modules/mimic-fn": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", - "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", - "engines": { - "node": ">=4" - } - }, - "node_modules/log-update/node_modules/onetime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", - "dependencies": { - "mimic-fn": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/log-update/node_modules/restore-cursor": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", - "dependencies": { - "onetime": "^2.0.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" - }, - "node_modules/mime-db": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", - "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.27", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", - "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", - "dependencies": { - "mime-db": "1.44.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" - }, - "node_modules/mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "dependencies": { - "minimist": "^1.2.5" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/number-is-nan": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "engines": { - "node": "*" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", - "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ospath": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", - "integrity": "sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs=" - }, - "node_modules/p-map": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", - "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", - "engines": { - "node": ">=6" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "engines": { - "node": ">=8" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" - }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" - }, - "node_modules/psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" - }, - "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/ramda": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz", - "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==" - }, - "node_modules/readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/request-progress": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", - "integrity": "sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4=", - "dependencies": { - "throttleit": "^1.0.0" - } - }, - "node_modules/restore-cursor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", - "integrity": "sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=", - "dependencies": { - "exit-hook": "^1.0.0", - "onetime": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/rxjs": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz", - "integrity": "sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==", - "dependencies": { - "tslib": "^1.9.0" - }, - "engines": { - "npm": ">=2.0.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", - "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" - }, - "node_modules/slice-ansi": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", - "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sshpk": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", - "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", - "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", - "dependencies": { - "ansi-regex": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/symbol-observable": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", - "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/throttleit": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz", - "integrity": "sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=" - }, - "node_modules/tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", - "dependencies": { - "rimraf": "^3.0.0" - }, - "engines": { - "node": ">=8.17.0" - } - }, - "node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dependencies": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" - }, - "node_modules/universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/uri-js": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.0.tgz", - "integrity": "sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g==", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", - "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", - "dependencies": { - "punycode": "1.3.2", - "querystring": "0.2.0" - } - }, - "node_modules/url/node_modules/punycode": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" - }, - "node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "bin": { - "uuid": "bin/uuid" - } - }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "engines": [ - "node >=0.6.0" - ], - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, - "node_modules/wait-on": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-5.3.0.tgz", - "integrity": "sha512-DwrHrnTK+/0QFaB9a8Ol5Lna3k7WvUR4jzSKmz0YaPBpuN2sACyiPVKVfj6ejnjcajAcvn3wlbTyMIn9AZouOg==", - "dependencies": { - "axios": "^0.21.1", - "joi": "^17.3.0", - "lodash": "^4.17.21", - "minimist": "^1.2.5", - "rxjs": "^6.6.3" - }, - "bin": { - "wait-on": "bin/wait-on" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-3.0.1.tgz", - "integrity": "sha1-KIoE2H7aXChuBg3+jxNc6NAH+Lo=", - "dependencies": { - "string-width": "^2.1.1", - "strip-ansi": "^4.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", - "engines": { - "node": ">=4" - } - }, - "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", - "engines": { - "node": ">=4" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dependencies": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "dependencies": { - "ansi-regex": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - } - }, "dependencies": { "@cypress/listr-verbose-renderer": { "version": "0.4.1", diff --git a/src/cryptoadvance/specter/internal_node.py b/src/cryptoadvance/specter/internal_node.py new file mode 100644 index 0000000000..d87ab5cc00 --- /dev/null +++ b/src/cryptoadvance/specter/internal_node.py @@ -0,0 +1,148 @@ +import json +import logging +import os + +from .helpers import is_testnet +from .specter_error import SpecterError, ExtProcTimeoutException +from .rpc import ( + BitcoinRPC, + RpcError, + autodetect_rpc_confs, + detect_rpc_confs, + get_default_datadir, +) +from .bitcoind import BitcoindPlainController +from .persistence import write_node +from .node import Node + +logger = logging.getLogger(__name__) + + +class InternalNode(Node): + """A Node but other than Node, this one is managed by Specter. + So it has start and stop methods and one called is_bitcoind_running + """ + + def __init__( + self, + name, + alias, + autodetect, + datadir, + user, + password, + port, + host, + protocol, + fullpath, + manager, + bitcoind_path, + bitcoind_network, + version, + ): + super().__init__( + name, + alias, + autodetect, + datadir, + user, + password, + port, + host, + protocol, + False, + fullpath, + manager, + ) + + self.bitcoind_path = bitcoind_path + self.bitcoind_network = bitcoind_network + self._bitcoind = None + self.bitcoin_pid = False + self.version = version + + @classmethod + def from_json(cls, node_dict, manager, default_alias="", default_fullpath=""): + name = node_dict.get("name", "") + alias = node_dict.get("alias", default_alias) + autodetect = node_dict.get("autodetect", True) + datadir = node_dict.get("datadir", get_default_datadir()) + user = node_dict.get("user", "") + password = node_dict.get("password", "") + port = node_dict.get("port", None) + host = node_dict.get("host", "localhost") + protocol = node_dict.get("protocol", "http") + external_node = node_dict.get("external_node", True) + fullpath = node_dict.get("fullpath", default_fullpath) + bitcoind_path = node_dict.get("bitcoind_path", "") + bitcoind_network = node_dict.get("bitcoind_network", "mainnet") + version = node_dict.get("version", "") + + return cls( + name, + alias, + autodetect, + datadir, + user, + password, + port, + host, + protocol, + fullpath, + manager, + bitcoind_path, + bitcoind_network, + version, + ) + + @property + def json(self): + node_json = super().json + node_json["bitcoind_path"] = self.bitcoind_path + node_json["bitcoind_network"] = self.bitcoind_network + node_json["version"] = self.version + return node_json + + def start(self, timeout=15): + try: + self.bitcoind.start_bitcoind( + datadir=os.path.expanduser(self.datadir), + timeout=timeout, # At the initial startup, we don't wait on bitcoind + ) + except ExtProcTimeoutException as e: + logger.error(e) + e.check_logfile(os.path.join(self.datadir, "debug.log")) + logger.error(e.get_logger_friendly()) + except SpecterError as e: + logger.error(e) + # Likely files of bitcoind were not found. Maybe deleted by the user? + finally: + try: + self.bitcoin_pid = self.bitcoind.bitcoind_proc.pid + except Exception as e: + logger.error(e) + return self.update_rpc() + + def stop(self): + if self._bitcoind: + self._bitcoind.stop_bitcoind() + self.bitcoin_pid = False + + @property + def bitcoind(self): + if os.path.isfile(self.bitcoind_path): + if not self._bitcoind: + self._bitcoind = BitcoindPlainController( + bitcoind_path=self.bitcoind_path, + rpcport=8332, + network="mainnet", + rpcuser=self.user, + rpcpassword=self.password, + ) + return self._bitcoind + raise SpecterError( + "Bitcoin Core files missing. Make sure Bitcoin Core is installed within Specter" + ) + + def is_bitcoind_running(self): + return self._bitcoind and self._bitcoind.check_existing() diff --git a/src/cryptoadvance/specter/managers/config_manager.py b/src/cryptoadvance/specter/managers/config_manager.py index 146f06faf6..8b4f2af681 100644 --- a/src/cryptoadvance/specter/managers/config_manager.py +++ b/src/cryptoadvance/specter/managers/config_manager.py @@ -9,7 +9,6 @@ from ..helpers import deep_update from ..persistence import read_json_file, write_json_file -from ..rpc import RpcError, autodetect_rpc_confs, detect_rpc_confs, get_default_datadir from ..specter_error import SpecterError from .genericdata_manager import GenericDataManager @@ -31,25 +30,6 @@ def __init__(self, data_folder, config={}): super().__init__(data_folder) self.arg_config = config self.data = { - "rpc": { - "autodetect": True, - "datadir": get_default_datadir(), - "user": "", - "password": "", - "port": "", - "host": "localhost", # localhost - "protocol": "http", # https for the future - "external_node": True, - }, - "internal_node": { - "autodetect": False, - "datadir": os.path.join(self.data_folder, ".bitcoin"), - "user": "bitcoin", - "password": secrets.token_urlsafe(16), - "host": "localhost", # localhost - "protocol": "http", # https for the future - "port": 8332, - }, "auth": { "method": "none", "password_min_chars": 6, @@ -63,6 +43,7 @@ def __init__(self, data_folder, config={}): "regtest": "CUSTOM", "signet": "CUSTOM", }, + "active_node_alias": "default", "proxy_url": "socks5h://localhost:9050", # Tor proxy URL "only_tor": False, "tor_control_port": "", @@ -80,31 +61,11 @@ def __init__(self, data_folder, config={}): "validate_merkle_proofs": False, "fee_estimator": "mempool", "fee_estimator_custom_url": "", + # TODO: remove "bitcoind": False, - "bitcoind_internal_version": "", } self.check_config() - @property - def rpc_conf(self): - return ( - self.data["rpc"] - if self.data["rpc"].get("external_node", True) - else self.data["internal_node"] - ) - - def update_rpc(self, **kwargs): - need_update = kwargs.get("need_update", False) - for k in kwargs: - if k != "need_update" and self.rpc_conf[k] != kwargs[k]: - self.data[ - "rpc" - if self.data["rpc"].get("external_node", True) - else "internal_node" - ][k] = kwargs[k] - need_update = True - return need_update - def check_config(self): """ Updates config if file config have changed. @@ -132,26 +93,9 @@ def check_config(self): # config from constructor overrides file config deep_update(self.data, self.arg_config) - @property - def bitcoin_datadir(self): - if "datadir" in self.data["rpc"]: - if self.data["rpc"].get("external_node", True): - return os.path.expanduser(self.data["rpc"]["datadir"]) - else: - if "datadir" in self.data["internal_node"]: - return os.path.expanduser(self.data["internal_node"]["datadir"]) - return get_default_datadir() - - def set_bitcoind_pid(self, pid): - """set the control pid of the bitcoind daemon""" - if self.data.get("bitcoind", False) != pid: - self.data["bitcoind"] = pid - self._save() - - def update_use_external_node(self, use_external_node): - """set whatever specter should connect to internal or external node""" - assert isinstance(use_external_node, bool) - self.data["rpc"]["external_node"] = use_external_node + def update_active_node(self, node_alias): + """set the current active node to use""" + self.data["active_node_alias"] = node_alias self._save() def update_auth(self, method, rate_limit, registration_link_timeout): diff --git a/src/cryptoadvance/specter/device_manager.py b/src/cryptoadvance/specter/managers/device_manager.py similarity index 92% rename from src/cryptoadvance/specter/device_manager.py rename to src/cryptoadvance/specter/managers/device_manager.py index dd6dc318f1..ef4539dbe1 100644 --- a/src/cryptoadvance/specter/device_manager.py +++ b/src/cryptoadvance/specter/managers/device_manager.py @@ -1,12 +1,12 @@ import os import json import logging -from .helpers import alias, load_jsons -from .rpc import get_default_datadir +from ..helpers import alias, load_jsons +from ..rpc import get_default_datadir -from .devices import __all__ as device_classes -from .devices.generic import GenericDevice # default device type -from .persistence import write_device, delete_file, delete_folder +from ..devices import __all__ as device_classes +from ..devices.generic import GenericDevice # default device type +from ..persistence import write_device, delete_file, delete_folder logger = logging.getLogger(__name__) diff --git a/src/cryptoadvance/specter/managers/node_manager.py b/src/cryptoadvance/specter/managers/node_manager.py new file mode 100644 index 0000000000..80e4db177c --- /dev/null +++ b/src/cryptoadvance/specter/managers/node_manager.py @@ -0,0 +1,192 @@ +import os +import logging +import secrets + +from ..rpc import get_default_datadir +from ..specter_error import SpecterError +from ..persistence import write_node, delete_file +from ..helpers import alias, load_jsons +from ..node import Node +from ..internal_node import InternalNode + +logger = logging.getLogger(__name__) + + +class NodeManager: + # chain is required to manage wallets when bitcoind is not running + def __init__( + self, + proxy_url="socks5h://localhost:9050", + only_tor=False, + active_node="default", + bitcoind_path="", + internal_bitcoind_version="", + data_folder="", + ): + self.data_folder = data_folder + self._active_node = active_node + self.proxy_url = proxy_url + self.only_tor = only_tor + self.bitcoind_path = bitcoind_path + self.internal_bitcoind_version = internal_bitcoind_version + self.update(data_folder) + internal_nodes = [ + node for node in self.nodes.values() if not node.external_node + ] + for node in internal_nodes: + node.start() + + def update(self, data_folder=None): + if data_folder is not None: + self.data_folder = data_folder + if data_folder.startswith("~"): + data_folder = os.path.expanduser(data_folder) + # creating folders if they don't exist + if not os.path.isdir(data_folder): + os.mkdir(data_folder) + nodes = {} + nodes_files = load_jsons(self.data_folder, key="name") + for node_alias in nodes_files: + fullpath = os.path.join(self.data_folder, "%s.json" % node_alias) + node_class = ( + Node if nodes_files[node_alias]["external_node"] else InternalNode + ) + nodes[nodes_files[node_alias]["name"]] = node_class.from_json( + nodes_files[node_alias], + self, + default_alias=node_alias, + default_fullpath=fullpath, + ) + if not nodes: + self.add_node( + name="Bitcoin Core", + autodetect=True, + datadir=get_default_datadir(), + user="", + password="", + port=8332, + host="localhost", + protocol="http", + external_node=True, + default_alias="default", + ) + else: + self.nodes = nodes + + @property + def active_node(self): + return self.get_by_alias(self._active_node) + + @property + def nodes_names(self): + return sorted(self.nodes.keys()) + + def switch_node(self, node_alias): + # this will throw an error if the node doesn't exist + self._active_node = self.get_by_alias(node_alias).alias + + def get_by_alias(self, alias): + for node_name in self.nodes: + if self.nodes[node_name] and self.nodes[node_name].alias == alias: + return self.nodes[node_name] + raise SpecterError("Node %s does not exist!" % alias) + + def add_node( + self, + name, + autodetect, + datadir, + user, + password, + port, + host, + protocol, + external_node, + default_alias=None, + ): + if not default_alias: + node_alias = alias(name) + else: + node_alias = default_alias + fullpath = os.path.join(self.data_folder, "%s.json" % node_alias) + i = 2 + while os.path.isfile(fullpath): + node_alias = alias("%s %d" % (name, i)) + fullpath = os.path.join(self.data_folder, "%s.json" % node_alias) + i += 1 + + node = Node( + name, + node_alias, + autodetect, + datadir, + user, + password, + port, + host, + protocol, + external_node, + fullpath, + self, + ) + write_node(node, fullpath) + self.update() # reload files + logger.info("Added new node {}".format(node.alias)) + return node + + def add_internal_node( + self, + name, + default_alias=None, + ): + if not default_alias: + node_alias = alias(name) + else: + node_alias = default_alias + fullpath = os.path.join(self.data_folder, "%s.json" % node_alias) + i = 2 + while os.path.isfile(fullpath): + node_alias = alias("%s %d" % (name, i)) + fullpath = os.path.join(self.data_folder, "%s.json" % node_alias) + i += 1 + + node = InternalNode( + name, + node_alias, + False, + os.path.join(self.data_folder, f"{node_alias}/.bitcoin"), + "bitcoin", + secrets.token_urlsafe(16), + 8332, + "localhost", + "http", + fullpath, + self, + self.bitcoind_path, + "mainnet", + self.internal_bitcoind_version, + ) + write_node(node, fullpath) + self.update() # reload files + logger.info("Added new internal node {}".format(node.alias)) + return node + + def delete_node(self, node, specter): + logger.info("Deleting {}".format(node.alias)) + # Delete files + delete_file(node.fullpath) + del self.nodes[node.name] + if self._active_node == node.alias: + specter.update_active_node(next(iter(self.nodes.values())).alias) + self.update() + logger.info("Node {} was deleted successfully".format(node.alias)) + + # TODO: Refactor out later to allow multiple built in nodes + @property + def internal_node(self): + internal_nodes = [ + node for node in self.nodes.values() if not node.external_node + ] + if len(internal_nodes) < 1: + return self.add_internal_node("Specter Bitcoin") + return internal_nodes[0] diff --git a/src/cryptoadvance/specter/user_manager.py b/src/cryptoadvance/specter/managers/user_manager.py similarity index 96% rename from src/cryptoadvance/specter/user_manager.py rename to src/cryptoadvance/specter/managers/user_manager.py index cd2c7f5b36..26f3078f95 100644 --- a/src/cryptoadvance/specter/user_manager.py +++ b/src/cryptoadvance/specter/managers/user_manager.py @@ -1,8 +1,8 @@ import os import json import logging -from .persistence import read_json_file, write_json_file -from .user import User, hash_password +from ..persistence import read_json_file, write_json_file +from ..user import User, hash_password from flask_login import current_user logger = logging.getLogger(__name__) diff --git a/src/cryptoadvance/specter/wallet_manager.py b/src/cryptoadvance/specter/managers/wallet_manager.py similarity index 98% rename from src/cryptoadvance/specter/wallet_manager.py rename to src/cryptoadvance/specter/managers/wallet_manager.py index 7a70212a4a..aee6139ac3 100644 --- a/src/cryptoadvance/specter/wallet_manager.py +++ b/src/cryptoadvance/specter/managers/wallet_manager.py @@ -8,12 +8,12 @@ from collections import OrderedDict from io import BytesIO -from .helpers import alias, load_jsons -from .persistence import delete_file, delete_folder -from .rpc import RpcError, get_default_datadir -from .specter_error import SpecterError -from .util.descriptor import AddChecksum -from .wallet import Wallet +from ..helpers import alias, load_jsons +from ..persistence import delete_file, delete_folder +from ..rpc import RpcError, get_default_datadir +from ..specter_error import SpecterError +from ..util.descriptor import AddChecksum +from ..wallet import Wallet logger = logging.getLogger() diff --git a/src/cryptoadvance/specter/node.py b/src/cryptoadvance/specter/node.py new file mode 100644 index 0000000000..8027c2bcc0 --- /dev/null +++ b/src/cryptoadvance/specter/node.py @@ -0,0 +1,350 @@ +import json +import logging +import os + +from .helpers import is_testnet +from .rpc import ( + BitcoinRPC, + RpcError, + autodetect_rpc_confs, + detect_rpc_confs, + get_default_datadir, +) +from .persistence import write_node + +logger = logging.getLogger(__name__) + + +class Node: + """A NodeManager represents the connection to a Bitcoin and/o Liquid Node (Full-) node. + It can be created via Constructor or from_json, and mainly it can give you A + RPC-object to use the API. + One or many Nodes are managed via the NodeManager + """ + + def __init__( + self, + name, + alias, + autodetect, + datadir, + user, + password, + port, + host, + protocol, + external_node, + fullpath, + manager, + ): + """Constructor for your Node. + + :param name: arbitrary name + :param alias: Bad habit, doesn't seem to have business functionality + :param autodetect: Boolean, will use the datadir to derive config is yes + :param datadir: A directory where a bitcoin.conf can be found, relevant for autodetect + :param user: rpc-user + :param password: rpc-password + :param port: usually something like 8332 for mainnet, 18332 for testnet, 18443 for Regtest, 38332 for signet + :param host: domainname or ip-address. Don't add the protocol here + :param protocol: Usually https or http + :param external_node: should be True for Node and False for InternalNode + :param fullpath: it's assumed that you want to store it on disk AND decide about the fullpath upfront + :param manager: A NodeManager instance which will get notified if the Node's name changes, the proxy_url will get copied from the manager as well + """ + self.name = name + self.alias = alias + self.autodetect = autodetect + self.datadir = datadir + self.user = user + self.password = password + self.port = port + self.host = host + self.protocol = protocol + self.external_node = external_node + self.fullpath = fullpath + self.manager = manager + self.proxy_url = manager.proxy_url + self.only_tor = manager.only_tor + self.rpc = self.get_rpc() + + self.check_info() + + @classmethod + def from_json(cls, node_dict, manager, default_alias="", default_fullpath=""): + """Create a Node from json""" + name = node_dict.get("name", "") + alias = node_dict.get("alias", default_alias) + autodetect = node_dict.get("autodetect", True) + datadir = node_dict.get("datadir", get_default_datadir()) + user = node_dict.get("user", "") + password = node_dict.get("password", "") + port = node_dict.get("port", None) + host = node_dict.get("host", "localhost") + protocol = node_dict.get("protocol", "http") + external_node = node_dict.get("external_node", True) + fullpath = node_dict.get("fullpath", default_fullpath) + + return cls( + name, + alias, + autodetect, + datadir, + user, + password, + port, + host, + protocol, + external_node, + fullpath, + manager, + ) + + @property + def json(self): + """Get a json-representation of this Node""" + return { + "name": self.name, + "alias": self.alias, + "autodetect": self.autodetect, + "datadir": self.datadir, + "user": self.user, + "password": self.password, + "port": self.port, + "host": self.host, + "protocol": self.protocol, + "external_node": self.external_node, + "fullpath": self.fullpath, + } + + def get_rpc(self): + """ + Checks if config have changed, compares with old rpc + and returns new one if necessary + """ + if hasattr(self, "rpc"): + rpc = self.rpc + else: + rpc = None + if self.autodetect: + if self.port: + rpc_conf_arr = autodetect_rpc_confs( + datadir=os.path.expanduser(self.datadir), port=self.port + ) + else: + rpc_conf_arr = autodetect_rpc_confs( + datadir=os.path.expanduser(self.datadir) + ) + if len(rpc_conf_arr) > 0: + rpc = BitcoinRPC( + **rpc_conf_arr[0], proxy_url=self.proxy_url, only_tor=self.only_tor + ) + else: + # if autodetect is disabled and port is not defined + # we use default port 8332 + if not self.port: + self.port = 8332 + rpc = BitcoinRPC( + user=self.user, + password=self.password, + host=self.host, + port=self.port, + protocol=self.protocol, + proxy_url=self.proxy_url, + only_tor=self.only_tor, + ) + return rpc + + def update_rpc( + self, + autodetect=None, + datadir=None, + user=None, + password=None, + port=None, + host=None, + protocol=None, + ): + update_rpc = self.rpc is None or not self.rpc.test_connection() + if autodetect is not None and self.autodetect != autodetect: + self.autodetect = autodetect + update_rpc = True + if datadir is not None and self.datadir != datadir: + self.datadir = datadir + update_rpc = True + if user is not None and self.user != user: + self.user = user + update_rpc = True + if password is not None and self.password != password: + self.password = password + update_rpc = True + if port is not None and self.port != port: + self.port = port + update_rpc = True + if host is not None and self.host != host: + self.host = host + update_rpc = True + if protocol is not None and self.protocol != protocol: + self.protocol = protocol + update_rpc = True + if update_rpc: + self.rpc = self.get_rpc() + write_node(self, self.fullpath) + self.check_info() + return False if not self.rpc else self.rpc.test_connection() + + def rename(self, new_name): + logger.info("Renaming {}".format(self.alias)) + self.name = new_name + write_node(self, self.fullpath) + self.manager.update() + + def check_info(self): + self._is_configured = self.rpc is not None + self._is_running = False + if self._is_configured: + try: + res = [ + r["result"] + for r in self.rpc.multi( + [ + ("getblockchaininfo", None), + ("getnetworkinfo", None), + ("getmempoolinfo", None), + ("uptime", None), + ("getblockhash", 0), + ("scantxoutset", "status", []), + ] + ) + ] + self._info = res[0] + self._network_info = res[1] + self._info["mempool_info"] = res[2] + self._info["uptime"] = res[3] + try: + self.rpc.getblockfilter(res[4]) + self._info["blockfilterindex"] = True + except: + self._info["blockfilterindex"] = False + self._info["utxorescan"] = ( + res[5]["progress"] + if res[5] is not None and "progress" in res[5] + else None + ) + if self._info["utxorescan"] is None: + self.utxorescanwallet = None + self._is_running = True + except Exception as e: + self._info = {"chain": None} + self._network_info = {"subversion": "", "version": 999999} + logger.error("Exception %s while check_node_info()" % e) + pass + else: + self._info = {"chain": None} + self._network_info = {"subversion": "", "version": 999999} + + if not self._is_running: + self._info["chain"] = None + + def test_rpc(self): + """tests the rpc-connection and returns a dict which helps + to derive what might be wrong with the config + ToDo: list an example here. + """ + if self.rpc is None: + return {"out": "", "err": "autodetect failed", "code": -1} + r = {} + r["tests"] = {"connectable": False} + r["err"] = "" + r["code"] = 0 + try: + r["tests"]["recent_version"] = ( + int(self.rpc.getnetworkinfo()["version"]) >= 170000 + ) + if not r["tests"]["recent_version"]: + r["err"] = "Core Node might be too old" + + r["tests"]["connectable"] = True + r["tests"]["credentials"] = True + try: + self.rpc.listwallets() + r["tests"]["wallets"] = True + except RpcError as rpce: + logger.error(rpce) + r["tests"]["wallets"] = False + r["err"] = "Wallets disabled" + + r["out"] = json.dumps(self.rpc.getblockchaininfo(), indent=4) + except ConnectionError as e: + logger.error("Caught an ConnectionError while test_rpc: %s", e) + + r["tests"]["connectable"] = False + r["err"] = "Failed to connect!" + r["code"] = -1 + except RpcError as rpce: + logger.error("Caught an RpcError while test_rpc: %s", rpce) + logger.error(rpce.status_code) + r["tests"]["connectable"] = True + r["code"] = self.rpc.r.status_code + if rpce.status_code == 401: + r["tests"]["credentials"] = False + r["err"] = "RPC authentication failed!" + else: + r["err"] = str(rpce.status_code) + except Exception as e: + logger.error( + "Caught an exception of type {} while test_rpc: {}".format( + type(e), str(e) + ) + ) + r["out"] = "" + if self.rpc.r is not None and "error" in self.rpc.r: + r["err"] = self.rpc.r["error"] + r["code"] = self.rpc.r.status_code + else: + r["err"] = "Failed to connect" + r["code"] = -1 + return r + + def abortrescanutxo(self): + """use this to abort a rescan as it stores some state while rescanning""" + self.rpc.scantxoutset("abort", []) + # Bitcoin Core doesn't catch up right away + # so app.specter.check() doesn't work + self._info["utxorescan"] = None + self.utxorescanwallet = None + + def check_blockheight(self): + return self.info["blocks"] != self.rpc.getblockcount() + + @property + def is_running(self): + return self._is_running + + @property + def is_configured(self): + return self._is_configured + + @property + def info(self): + return self._info + + @property + def network_info(self): + return self._network_info + + @property + def bitcoin_core_version(self): + return self.network_info["subversion"].replace("/", "").replace("Satoshi:", "") + + @property + def bitcoin_core_version_raw(self): + return self.network_info["version"] + + @property + def chain(self): + return self.info["chain"] + + @property + def is_testnet(self): + return is_testnet(self.chain) diff --git a/src/cryptoadvance/specter/persistence.py b/src/cryptoadvance/specter/persistence.py index 9d3c589a82..9ccb44b4cf 100644 --- a/src/cryptoadvance/specter/persistence.py +++ b/src/cryptoadvance/specter/persistence.py @@ -128,6 +128,11 @@ def write_device(device, fullpath): storage_callback() +def write_node(node, fullpath): + _write_json_file(node.json, fullpath) + storage_callback() + + def delete_folder(path): _delete_folder(path) storage_callback() diff --git a/src/cryptoadvance/specter/server.py b/src/cryptoadvance/specter/server.py index 89fdad482c..0a314446b6 100644 --- a/src/cryptoadvance/specter/server.py +++ b/src/cryptoadvance/specter/server.py @@ -85,6 +85,7 @@ def init_app(app, hwibridge=False, specter=None): specter = Specter( data_folder=app.config["SPECTER_DATA_FOLDER"], config=app.config["DEFAULT_SPECTER_CONFIG"], + internal_bitcoind_version=app.config["INTERNAL_BITCOIND_VERSION"], ) # version checker diff --git a/src/cryptoadvance/specter/server_endpoints/auth.py b/src/cryptoadvance/specter/server_endpoints/auth.py index 844d4c5f38..cf0c7469e8 100644 --- a/src/cryptoadvance/specter/server_endpoints/auth.py +++ b/src/cryptoadvance/specter/server_endpoints/auth.py @@ -36,7 +36,7 @@ def login(): if auth["method"] == "rpcpasswordaspin": # TODO: check the password via RPC-call if app.specter.rpc is None: - if app.specter.config["rpc"]["password"] == request.form["password"]: + if app.specter.node.password == request.form["password"]: app.login("admin") app.logger.info( "AUDIT: Successfull Login via RPC-credentials (node disconnected)" diff --git a/src/cryptoadvance/specter/server_endpoints/controller.py b/src/cryptoadvance/specter/server_endpoints/controller.py index d6d37ec32b..fa534fbbcc 100644 --- a/src/cryptoadvance/specter/server_endpoints/controller.py +++ b/src/cryptoadvance/specter/server_endpoints/controller.py @@ -27,6 +27,7 @@ # Setup specter endpoints from .auth import auth_endpoint from .devices import devices_endpoint +from .nodes import nodes_endpoint from .price import price_endpoint from .settings import settings_endpoint from .setup import setup_endpoint @@ -35,6 +36,7 @@ app.register_blueprint(auth_endpoint, url_prefix="/auth") app.register_blueprint(devices_endpoint, url_prefix="/devices") +app.register_blueprint(nodes_endpoint, url_prefix="/nodes") app.register_blueprint(price_endpoint, url_prefix="/price") app.register_blueprint(settings_endpoint, url_prefix="/settings") app.register_blueprint(setup_endpoint, url_prefix="/setup") @@ -95,7 +97,12 @@ def server_error_timeout(e): "Bitcoin Core is not coming up in time. Maybe it's just slow but please check the logs below", "warn", ) - return redirect(url_for("settings_endpoint.bitcoin_core_internal_logs")) + return redirect( + url_for( + "node_settings.bitcoin_core_internal_logs", + node_alias=app.specter.node.alias, + ) + ) @app.errorhandler(CSRFError) diff --git a/src/cryptoadvance/specter/server_endpoints/devices.py b/src/cryptoadvance/specter/server_endpoints/devices.py index b34a226bcc..3229dae120 100644 --- a/src/cryptoadvance/specter/server_endpoints/devices.py +++ b/src/cryptoadvance/specter/server_endpoints/devices.py @@ -15,9 +15,9 @@ from mnemonic import Mnemonic from ..helpers import is_testnet, generate_mnemonic from ..key import Key -from ..device_manager import get_device_class +from ..managers.device_manager import get_device_class from ..devices.bitcoin_core import BitcoinCore -from ..wallet_manager import purposes +from ..managers.wallet_manager import purposes from ..specter_error import handle_exception rand = random.randint(0, 1e32) # to force style refresh diff --git a/src/cryptoadvance/specter/server_endpoints/nodes.py b/src/cryptoadvance/specter/server_endpoints/nodes.py new file mode 100644 index 0000000000..974191c453 --- /dev/null +++ b/src/cryptoadvance/specter/server_endpoints/nodes.py @@ -0,0 +1,334 @@ +import copy, random, json, time, os, shutil, logging + +from flask import ( + Flask, + Blueprint, + render_template, + request, + redirect, + url_for, + jsonify, + flash, +) +from flask_login import login_required, current_user +from flask import current_app as app +from ..rpc import get_default_datadir +from ..node import Node +from ..specter_error import ExtProcTimeoutException +from ..util.shell import get_last_lines_from_file + +logger = logging.getLogger(__name__) + +rand = random.randint(0, 1e32) # to force style refresh + +# Setup endpoint blueprint +nodes_endpoint = Blueprint("nodes_endpoint", __name__) + + +@nodes_endpoint.route( + "new_node/", defaults={"node_alias": None}, methods=["GET", "POST"] +) +@nodes_endpoint.route("node//", methods=["GET", "POST"]) +@login_required +def node_settings(node_alias): + if node_alias: + try: + node = app.specter.node_manager.get_by_alias(node_alias) + if not node.external_node: + return redirect( + url_for( + "nodes_endpoint.internal_node_settings", + node_alias=node.alias, + ) + ) + except: + return render_template( + "base.jinja", error="Node not found", specter=app.specter, rand=rand + ) + else: + node = Node.from_json( + { + "name": "New Node", + "autodetect": True, + "datadir": get_default_datadir(), + "user": "", + "password": "", + "port": 8332, + "host": "localhost", + "protocol": "http", + "external_node": True, + }, + app.specter.node_manager, + ) + + if not current_user.is_admin: + flash("Only an admin is allowed to access this page.", "error") + return redirect("") + # The node might have been down but is now up again + # (and the checker did not realized yet) and the user clicked "Configure Node" + if node.rpc is None and node_alias: + node.update_rpc() + + test = None + if request.method == "POST": + action = request.form["action"] + + if action != "rename": + autodetect = "autodetect" in request.form + if autodetect: + datadir = request.form["datadir"] + else: + datadir = "" + user = request.form["username"] + password = request.form["password"] + port = request.form["port"] + host = request.form["host"].rstrip("/") + # protocol://host + if "://" in host: + arr = host.split("://") + protocol = arr[0] + host = arr[1] + if not node_alias: + node.name = request.form["name"] + + if action == "rename": + node_name = request.form["newtitle"] + if not node_name: + flash("Node name must not be empty", "error") + elif node_name == node.name: + pass + elif node_name in app.specter.device_manager.devices_names: + flash("Node with this name already exists", "error") + else: + node.rename(node_name) + elif action == "forget": + if not node_alias: + flash("Failed to deleted node. Node isn't saved", "error") + elif len(app.specter.node_manager.nodes) > 1: + app.specter.node_manager.delete_node(node, app.specter) + flash("Node deleted successfully") + return redirect( + url_for( + "nodes_endpoint.node_settings", + node_alias=app.specter.node.alias, + ) + ) + else: + flash( + "Failed to deleted node. Specter must have at least one node configured", + "error", + ) + elif action == "test": + # If this is failing, the test_rpc-method needs improvement + # Don't wrap this into a try/except otherwise the feedback + # of what's wrong to the user gets broken + node = Node( + node.name, + node.alias, + autodetect, + datadir, + user, + password, + port, + host, + protocol, + node.external_node, + node.fullpath, + node.manager, + ) + test = node.test_rpc() + + if "tests" in test: + # If any test has failed, we notify the user that the test has not passed + if False in list(test["tests"].values()): + flash(f"Test failed: {test['err']}", "error") + else: + flash("Test passed", "info") + elif action == "save": + if not node_alias: + if node.name in app.specter.node_manager.nodes: + flash( + "Node with this name already exits, please choose a different name.", + "error", + ) + return render_template( + "node/node_settings.jinja", + node=node, + node_alias=node_alias, + test=test, + specter=app.specter, + rand=rand, + ) + node = app.specter.node_manager.add_node( + node.name, + autodetect, + datadir, + user, + password, + port, + host, + protocol, + node.external_node, + ) + app.specter.update_active_node(node.alias) + return redirect( + url_for("nodes_endpoint.node_settings", node_alias=node.alias) + ) + + success = node.update_rpc( + autodetect=autodetect, + datadir=datadir, + user=user, + password=password, + port=port, + host=host, + protocol=protocol, + ) + if not success: + flash("Failed connecting to the node", "error") + if app.specter.active_node_alias == node.alias: + app.specter.check() + + return render_template( + "node/node_settings.jinja", + node=node, + node_alias=node_alias, + test=test, + specter=app.specter, + rand=rand, + ) + + +@nodes_endpoint.route("specter_node//", methods=["GET", "POST"]) +@login_required +def internal_node_settings(node_alias): + err = None + if node_alias: + try: + node = app.specter.node_manager.get_by_alias(node_alias) + if node.external_node: + return redirect( + url_for( + "nodes_endpoint.node_settings", + node_alias=node.alias, + ) + ) + except: + return render_template( + "base.jinja", error="Node not found", specter=app.specter, rand=rand + ) + else: + # TODO: Allow internal node setup here? + return redirect( + url_for( + "nodes_endpoint.internal_node_settings", + node_alias=node.alias, + ) + ) + + if not current_user.is_admin: + flash("Only an admin is allowed to access this page.", "error") + return redirect("") + # The node might have been down but is now up again + # (and the checker did not realized yet) and the user clicked "Configure Node" + if node.rpc is None: + node.update_rpc() + + if request.method == "POST": + action = request.form["action"] + + if action == "rename": + node_name = request.form["newtitle"] + if not node_name: + flash("Node name must not be empty", "error") + elif node_name == node.name: + pass + elif node_name in app.specter.device_manager.devices_names: + flash("Node with this name already exists", "error") + else: + node.rename(node_name) + elif action == "forget": + if not node_alias: + flash("Failed to deleted node. Node isn't saved", "error") + elif len(app.specter.node_manager.nodes) > 1: + app.specter.node_manager.delete_node(node, app.specter) + if bool(request.form.get("remove_datadir", False)): + shutil.rmtree(os.path.expanduser(node.datadir), ignore_errors=True) + flash("Node deleted successfully") + return redirect( + url_for( + "nodes_endpoint.node_settings", + node_alias=app.specter.node.alias, + ) + ) + else: + flash( + "Failed to deleted node. Specter must have at least one node configured", + "error", + ) + elif action == "stopbitcoind": + try: + node.stop() + time.sleep(5) + flash("Specter stopped Bitcoin Core successfully") + except Exception as e: + try: + logger.exception(e) + flash("Stopping Bitcoin Core, this might take a few moments.") + node.rpc.stop() + except Exception as ne: + logger.exception(ne) + flash(f"Failed to stop Bitcoin Core {ne}", "error") + elif action == "startbitcoind": + if node.start(timeout=120): + flash("Specter has started Bitcoin Core") + else: + flash("Specter failed to start the node...", "error") + elif action == "uninstall_bitcoind": + try: + node.stop() + shutil.rmtree( + os.path.join(app.specter.data_folder, "bitcoin-binaries"), + ignore_errors=True, + ) + if bool(request.form.get("remove_datadir", False)): + shutil.rmtree(os.path.expanduser(node.datadir), ignore_errors=True) + flash(f"Bitcoin Core uninstalled successfully") + app.specter.node_manager.delete_node(node, app.specter) + return redirect( + url_for( + "nodes_endpoint.node_settings", + node_alias=app.specter.node.alias, + ) + ) + except Exception as e: + flash(f"Failed to remove Bitcoin Core, error: {e}", "error") + + return render_template( + "node/internal_node_settings.jinja", + node=node, + node_alias=node_alias, + specter=app.specter, + rand=rand, + ) + + +@nodes_endpoint.route("/internal_node_logs//", methods=["GET"]) +@login_required +def internal_node_logs(node_alias): + node = app.specter.node_manager.get_by_alias(node_alias) + logfile_location = os.path.join(node.datadir, "debug.log") + return render_template( + "node/internal_node_logs.jinja", + node_alias=node_alias, + specter=app.specter, + loglines="".join(get_last_lines_from_file(logfile_location)), + ) + + +@nodes_endpoint.route("switch_node/", methods=["POST"]) +@login_required +def switch_node(): + node_alias = request.form["node_alias"] + app.specter.update_active_node(node_alias) + return redirect(url_for("nodes_endpoint.node_settings", node_alias=node_alias)) diff --git a/src/cryptoadvance/specter/server_endpoints/settings.py b/src/cryptoadvance/specter/server_endpoints/settings.py index 1fe0a7b1dd..ec10815d1a 100644 --- a/src/cryptoadvance/specter/server_endpoints/settings.py +++ b/src/cryptoadvance/specter/server_endpoints/settings.py @@ -1,20 +1,23 @@ -import json, os, time, random, requests, secrets, platform, tarfile, zipfile, sys, shutil -import pgpy +import json +import logging +import os +import platform +import random +import secrets +import shutil +import sys +import tarfile +import time +import zipfile from pathlib import Path -from flask import ( - Flask, - Blueprint, - render_template, - request, - redirect, - url_for, - jsonify, - flash, - send_file, -) -from flask_login import login_required, current_user +import pgpy +import requests +from flask import Blueprint, Flask from flask import current_app as app +from flask import flash, jsonify, redirect, render_template, request, send_file, url_for +from flask_login import current_user, login_required + from ..helpers import ( get_loglevel, get_startblock_by_chain, @@ -22,11 +25,13 @@ set_loglevel, ) from ..persistence import write_devices, write_wallet +from ..specter_error import ExtProcTimeoutException, handle_exception from ..user import hash_password -from ..util.tor import start_hidden_service, stop_hidden_services from ..util.sha256sum import sha256sum from ..util.shell import get_last_lines_from_file -from ..specter_error import handle_exception, ExtProcTimeoutException +from ..util.tor import start_hidden_service, stop_hidden_services + +logger = logging.getLogger(__name__) rand = random.randint(0, 1e32) # to force style refresh @@ -37,181 +42,7 @@ @settings_endpoint.route("/", methods=["GET"]) @login_required def settings(): - if current_user.is_admin: - return redirect(url_for("settings_endpoint.bitcoin_core")) - else: - return redirect(url_for("settings_endpoint.general")) - - -@settings_endpoint.route("/bitcoin_core/internal_logs", methods=["GET"]) -@login_required -def bitcoin_core_internal_logs(): - logfile_location = os.path.join( - app.specter.config["internal_node"]["datadir"], "debug.log" - ) - return render_template( - "settings/bitcoin_core_internal_logs.jinja", - specter=app.specter, - loglines="".join(get_last_lines_from_file(logfile_location)), - ) - - -@settings_endpoint.route("/bitcoin_core", methods=["GET", "POST"]) -@login_required -def bitcoin_core(): - current_version = notify_upgrade(app, flash) - if not current_user.is_admin: - flash("Only an admin is allowed to access this page.", "error") - return redirect("") - # The node might have been down but is now up again - # (and the checker did not realized yet) and the user clicked "Configure Node" - if app.specter.rpc is None: - app.specter.check() - rpc = app.specter.config["rpc"] - user = rpc["user"] - password = rpc["password"] - port = rpc["port"] - host = rpc["host"] - protocol = "http" - autodetect = rpc["autodetect"] - datadir = rpc["datadir"] - external_node = rpc["external_node"] - node_view = "external" if external_node else "internal" - err = None - - if "protocol" in rpc: - protocol = rpc["protocol"] - test = None - if request.method == "POST": - action = request.form["action"] - if action == "test" or action == "save": - autodetect = "autodetect" in request.form - if autodetect: - datadir = request.form["datadir"] - user = request.form["username"] - password = request.form["password"] - port = request.form["port"] - host = request.form["host"].rstrip("/") - - # protocol://host - if "://" in host: - arr = host.split("://") - protocol = arr[0] - host = arr[1] - - if action == "test": - # If this is failing, the test_rpc-method needs improvement - # Don't wrap this into a try/except otherwise the feedback - # of what's wron to the user gets broken - test = app.specter.test_rpc( - user=user, - password=password, - port=port, - host=host, - protocol=protocol, - autodetect=autodetect, - datadir=datadir, - ) - node_view = "external" - - if "tests" in test: - # If any test has failed, we notify the user that the test has not passed - if False in list(test["tests"].values()): - flash(f"Test failed: {test['err']}", "error") - else: - flash("Test passed", "info") - elif action == "save": - if current_user.is_admin: - node_view = "external" - success = app.specter.update_rpc( - user=user, - password=password, - port=port, - host=host, - protocol=protocol, - autodetect=autodetect, - datadir=datadir, - ) - if not success: - flash("Failed connecting to the node", "error") - app.specter.check() - # Internal Node actions - elif action == "useinternal": - app.specter.update_use_external_node(False) - external_node = False - node_view = "internal" - elif action == "useexternal": - app.specter.update_use_external_node(True) - external_node = True - node_view = "external" - elif action == "stopbitcoind": - node_view = "internal" - try: - app.specter.bitcoind.stop_bitcoind() - app.specter.set_bitcoind_pid(False) - time.sleep(5) - flash("Specter stopped Bitcoin Core successfully") - except Exception: - try: - flash("Stopping Bitcoin Core, this might take a few moments.") - app.specter.rpc.stop() - except Exception as e: - flash(f"Failed to stop Bitcoin Core {e}", "error") - elif action == "startbitcoind": - node_view = "internal" - try: - app.specter.bitcoind.start_bitcoind( - datadir=os.path.expanduser( - app.specter.config["internal_node"]["datadir"] - ) - ) - except ExtProcTimeoutException as e: - e.check_logfile( - os.path.join( - app.specter.config["internal_node"]["datadir"], "debug.log" - ) - ) - raise e - finally: - app.specter.set_bitcoind_pid(app.specter.bitcoind.bitcoind_proc.pid) - time.sleep(15) - flash("Specter has started Bitcoin Core") - elif action == "uninstall_bitcoind": - try: - if app.specter.is_bitcoind_running(): - app.specter.bitcoind.stop_bitcoind() - shutil.rmtree(os.path.join(app.specter.data_folder, "bitcoin-binaries")) - if bool(request.form.get("remove_datadir", False)): - shutil.rmtree( - os.path.expanduser( - app.specter.config["internal_node"]["datadir"] - ) - ) - flash(f"Bitcoin Core uninstalled successfully") - except Exception as e: - flash(f"Failed to remove Bitcoin Core, error: {e}", "error") - - app.specter.check() - - return render_template( - "settings/bitcoin_core_settings.jinja", - test=test, - autodetect=autodetect, - datadir=datadir, - username=user, - password=password, - port=port, - host=host, - protocol=protocol, - specter=app.specter, - current_version=current_version, - bitcoind_exists=os.path.isfile(app.specter.bitcoind_path), - is_running=app.specter.is_bitcoind_running(), - node_view=node_view, - external_node=external_node, - error=err, - rand=rand, - ) + return redirect(url_for("settings_endpoint.general")) @settings_endpoint.route("/general", methods=["GET", "POST"]) @@ -372,6 +203,7 @@ def tor(): flash("Specter has started Tor") except Exception as e: flash(f"Failed to start Tor, error: {e}", "error") + logger.error(f"Failed to start Tor, error: {e}") elif action == "stoptor": try: app.specter.tor_daemon.stop_tor_daemon() @@ -379,6 +211,7 @@ def tor(): flash("Specter stopped Tor successfully") except Exception as e: flash(f"Failed to stop Tor, error: {e}", "error") + logger.error(f"Failed to start Tor, error: {e}") elif action == "uninstalltor": try: if app.specter.is_tor_dameon_running(): @@ -387,7 +220,8 @@ def tor(): os.remove(os.path.join(app.specter.data_folder, "torrc")) flash(f"Tor uninstalled successfully") except Exception as e: - flash(f"Failed to stop Tor, error: {e}", "error") + flash(f"Failed to uninstall Tor, error: {e}", "error") + logger.error(f"Failed to uninstall Tor, error: {e}") elif action == "test_tor": try: requests_session = requests.Session() @@ -396,14 +230,28 @@ def tor(): res = requests_session.get( # "http://expyuzz4wqqyqhjn.onion", # Tor Project onion website (seems to be down) "https://protonirockerxow.onion", # Proton mail onion website + timeout=10, ) tor_connectable = res.status_code == 200 if tor_connectable: flash("Tor requests test completed successfully!", "info") + logger.error("Tor-Logs:") + logger.error(app.specter.tor_daemon.get_logs()) else: - flash("Failed to make test request over Tor.", "error") + flash( + f"Failed to make test request over Tor. Status-Code: {res.status_code}", + "error", + ) + logger.error( + f"Failed to make test request over Tor. Status-Code: {res.status_code}" + ) + logger.error("Tor-Logs:") + logger.error(app.specter.tor_daemon.get_logs()) except Exception as e: - flash("Failed to make test request over Tor.\nError: %s" % e, "error") + flash(f"Failed to make test request over Tor.\nError: {e}", "error") + logger.error(f"Failed to make test request over Tor.\nError: {e}") + logger.error("Tor-Logs:") + logger.error(app.specter.tor_daemon.get_logs()) tor_connectable = False elif action == "toggle_hidden_service": if not app.config["DEBUG"]: diff --git a/src/cryptoadvance/specter/server_endpoints/setup.py b/src/cryptoadvance/specter/server_endpoints/setup.py index e4a91d2059..8a540d2aef 100644 --- a/src/cryptoadvance/specter/server_endpoints/setup.py +++ b/src/cryptoadvance/specter/server_endpoints/setup.py @@ -15,9 +15,9 @@ from mnemonic import Mnemonic from ..helpers import is_testnet, generate_mnemonic from ..key import Key -from ..device_manager import get_device_class +from ..managers.device_manager import get_device_class from ..devices.bitcoin_core import BitcoinCore -from ..wallet_manager import purposes +from ..managers.wallet_manager import purposes from ..specter_error import handle_exception from ..util.bitcoind_setup_tasks import ( setup_bitcoind_thread, @@ -121,14 +121,17 @@ def setup_tor(): @setup_endpoint.route("/setup_bitcoind", methods=["POST"]) @login_required def setup_bitcoind(): - app.specter.config["internal_node"]["datadir"] = request.form.get( - "bitcoin_core_datadir", app.specter.config["internal_node"]["datadir"] + app.specter.node_manager.internal_node.update_rpc( + datadir=request.form.get( + "bitcoin_core_datadir", app.specter.node_manager.internal_node.datadir + ), ) - app.specter._save() - if os.path.exists(app.specter.config["internal_node"]["datadir"]): + if os.path.exists(app.specter.node_manager.internal_node.datadir): if request.form["override_data_folder"] != "true": return {"error": "data folder already exists"} - shutil.rmtree(app.specter.config["internal_node"]["datadir"]) + shutil.rmtree( + app.specter.node_manager.internal_node.datadir, ignore_errors=True + ) if ( not os.path.isfile(app.specter.bitcoind_path) and app.specter.setup_status["bitcoind"]["stage_progress"] == -1 diff --git a/src/cryptoadvance/specter/server_endpoints/wallets.py b/src/cryptoadvance/specter/server_endpoints/wallets.py index fa6d94e92a..e9ae9e07c0 100644 --- a/src/cryptoadvance/specter/server_endpoints/wallets.py +++ b/src/cryptoadvance/specter/server_endpoints/wallets.py @@ -37,7 +37,7 @@ from ..util.fee_estimation import get_fees from ..util.price_providers import get_price_at from ..util.tx import decoderawtransaction -from ..wallet_manager import purposes +from ..managers.wallet_manager import purposes rand = random.randint(0, 1e32) # to force style refresh diff --git a/src/cryptoadvance/specter/specter.py b/src/cryptoadvance/specter/specter.py index 577bf23eb9..7c15d3bbad 100644 --- a/src/cryptoadvance/specter/specter.py +++ b/src/cryptoadvance/specter/specter.py @@ -19,10 +19,12 @@ from urllib3.exceptions import NewConnectionError from requests.exceptions import ConnectionError from .rpc import BitcoinRPC -from .device_manager import DeviceManager -from .wallet_manager import WalletManager -from .user_manager import UserManager -from .persistence import write_json_file, read_json_file +from .managers.device_manager import DeviceManager +from .managers.wallet_manager import WalletManager +from .managers.user_manager import UserManager +from .managers.otp_manager import OtpManager +from .managers.config_manager import ConfigManager +from .persistence import write_json_file, read_json_file, write_node from .user import User from .util.price_providers import update_price from .util.tor import get_tor_daemon_suffix @@ -32,70 +34,20 @@ from .specter_error import SpecterError, ExtProcTimeoutException from sys import exit from .util.setup_states import SETUP_STATES -from .managers.otp_manager import OtpManager -from .managers.config_manager import ConfigManager +from .node import Node +from .internal_node import InternalNode +from .managers.node_manager import NodeManager logger = logging.getLogger(__name__) -def get_rpc( - conf, - old_rpc=None, - return_broken_instead_none=False, - proxy_url="socks5h://localhost:9050", - only_tor=False, -): - """ - Checks if config have changed, compares with old rpc - and returns new one if necessary - If there is no working rpc-connection, it has to return None - If return_broken_instead_none is True, it'll return even a broken connection. - """ - if "autodetect" not in conf: - conf["autodetect"] = True - rpc = None - if conf["autodetect"]: - if "port" in conf: - rpc_conf_arr = autodetect_rpc_confs( - datadir=os.path.expanduser(conf["datadir"]), port=conf["port"] - ) - else: - rpc_conf_arr = autodetect_rpc_confs( - datadir=os.path.expanduser(conf["datadir"]) - ) - if len(rpc_conf_arr) > 0: - rpc = BitcoinRPC(**rpc_conf_arr[0], proxy_url=proxy_url, only_tor=only_tor) - else: - # if autodetect is disabled and port is not defined - # we use default port 8332 - if not conf.get("port", None): - conf["port"] = 8332 - rpc = BitcoinRPC(**conf) - if return_broken_instead_none: - return rpc - # check if we have something to compare with - if old_rpc is None: - return rpc if rpc and rpc.test_connection() else None - # check if we have something detected - if rpc is None: - # check if old rpc is still valid - return old_rpc if old_rpc.test_connection() else None - # check if something has changed and return new rpc if so. - # RPC cookie will have a new password if bitcoind is restarted. - if rpc.url == old_rpc.url and rpc.password == old_rpc.password: - return old_rpc - else: - logger.info("rpc config have changed.") - return rpc - - class Specter: """A central Object mostly holding app-settings""" # use this lock for all fs operations lock = threading.Lock() - def __init__(self, data_folder="./data", config={}): + def __init__(self, data_folder="./data", config={}, internal_bitcoind_version=""): if data_folder.startswith("~"): data_folder = os.path.expanduser(data_folder) data_folder = os.path.abspath(data_folder) @@ -106,33 +58,30 @@ def __init__(self, data_folder="./data", config={}): self.data_folder = data_folder - # the rpc-object. Currently we only have one. If we have Node-Managers, we would need - # either many of them and register them with a keyword or something like that - self.rpc = None - - # wallet that is currently rescanning with utxorescan - # can be only one at a time - self.utxorescanwallet = None - self.user_manager = UserManager(self) self._config_manager = ConfigManager(self.data_folder, config) - self.torbrowser_path = os.path.join( - self.data_folder, f"tor-binaries/tor{get_tor_daemon_suffix()}" - ) + self.internal_bitcoind_version = internal_bitcoind_version + + # Migrating from Specter 1.3.1 and lower (prior to the node manager) + self.migrate_old_node_format() - self.bitcoind_path = os.path.join( - self.data_folder, "bitcoin-binaries/bin/bitcoind" + self.node_manager = NodeManager( + proxy_url=self.proxy_url, + only_tor=self.only_tor, + active_node=self.active_node_alias, + bitcoind_path=self.bitcoind_path, + internal_bitcoind_version=internal_bitcoind_version, + data_folder=os.path.join(self.data_folder, "nodes"), ) - if platform.system() == "Windows": - self.bitcoind_path += ".exe" + self.torbrowser_path = os.path.join( + self.data_folder, f"tor-binaries/tor{get_tor_daemon_suffix()}" + ) - self._bitcoind = None self._tor_daemon = None - self.node_status = None self.setup_status = { "stage": "start", "bitcoind": { @@ -150,46 +99,13 @@ def __init__(self, data_folder="./data", config={}): # also loads and checks wallets for all users try: self.check(check_all=True) - rpc_conf = next( - ( - conf - for conf in detect_rpc_confs( - datadir=os.path.expanduser( - self.config["rpc"]["datadir"] - if self.config["rpc"].get("external_node", True) - else self.config["internal_node"]["datadir"] - ) - ) - if conf["port"] == 8332 - ), - None, - ) if os.path.isfile(self.torbrowser_path): self.tor_daemon.start_tor_daemon() except Exception as e: logger.error(e) - if not self.config_manager.data["rpc"].get("external_node", True): - try: - self.bitcoind.start_bitcoind( - datadir=os.path.expanduser(self.config["internal_node"]["datadir"]), - timeout=15, # At the initial startup, we don't wait on bitcoind - ) - except ExtProcTimeoutException as e: - logger.error(e) - e.check_logfile( - os.path.join(self.config["internal_node"]["datadir"], "debug.log") - ) - logger.error(e.get_logger_friendly()) - except SpecterError as e: - logger.error(e) - # Likely files of bitcoind were not found. Maybe deleted by the user? - finally: - try: - self.set_bitcoind_pid(self.bitcoind.bitcoind_proc.pid) - except Exception as e: - logger.error(e) + ################################################################################ self.update_tor_controller() self.checker = Checker(lambda: self.check(check_all=True), desc="health") self.checker.start() @@ -209,9 +125,9 @@ def cleanup_on_exit(self, signum=0, frame=0): logger.info("Specter exit cleanup: Stopping Tor daemon") self._tor_daemon.stop_tor_daemon() - if self._bitcoind: - logger.info("Specter exit cleanup: Stopping bitcoind") - self._bitcoind.stop_bitcoind() + for node in self.node_manager.nodes.values(): + if not node.external_node: + node.stop() logger.info("Closing Specter after cleanup") # For some reason we need to explicitely exit here. Otherwise it will hang @@ -229,27 +145,16 @@ def check(self, user=None, check_all=False): # check if config file have changed self.check_config() - # update rpc if something doesn't work - rpc = self.rpc - if rpc is None or not rpc.test_connection(): - rpc = get_rpc( - self.config_manager.rpc_conf, - self.rpc, - proxy_url=self.proxy_url, - only_tor=self.only_tor, - ) - - self.check_node_info() + self.node.update_rpc() # if rpc is not available # do checks more often, once in 20 seconds - if rpc is None or self.info.get("initialblockdownload", True): + if self.rpc is None or self.node.info.get("initialblockdownload", True): period = 20 else: period = 600 if hasattr(self, "checker") and self.checker.period != period: self.checker.period = period - self.rpc = rpc if not check_all: # find proper user @@ -259,61 +164,29 @@ def check(self, user=None, check_all=False): for u in self.user_manager.users: u.check() + @property + def node(self): + try: + return self.node_manager.active_node + except SpecterError as e: + self.update_active_node(list(self.node_manager.nodes.values())[0].alias) + return self.node_manager.active_node + + @property + def rpc(self): + return self.node.rpc + + @property + def utxorescanwallet(self): + return self.node.utxorescanwallet + @property def config(self): """A convenience property simply redirecting to the config_manager""" return self.config_manager.data - def check_node_info(self): - self._is_configured = self.rpc is not None - self._is_running = False - if self._is_configured: - try: - res = [ - r["result"] - for r in self.rpc.multi( - [ - ("getblockchaininfo", None), - ("getnetworkinfo", None), - ("getmempoolinfo", None), - ("uptime", None), - ("getblockhash", 0), - ("scantxoutset", "status", []), - ] - ) - ] - self._info = res[0] - self._network_info = res[1] - self._info["mempool_info"] = res[2] - self._info["uptime"] = res[3] - try: - self.rpc.getblockfilter(res[4]) - self._info["blockfilterindex"] = True - except: - self._info["blockfilterindex"] = False - self._info["utxorescan"] = ( - res[5]["progress"] - if res[5] is not None and "progress" in res[5] - else None - ) - if self._info["utxorescan"] is None: - self.utxorescanwallet = None - self._is_running = True - except Exception as e: - self._info = {"chain": None} - self._network_info = {"subversion": "", "version": 999999} - logger.error("Exception %s while specter.check()" % e) - pass - else: - self._info = {"chain": None} - self._network_info = {"subversion": "", "version": 999999} - - if not self._is_running: - self._info["chain"] = None - def check_blockheight(self): - current_blockheight = self.rpc.getblockcount() - if self.info["blocks"] != current_blockheight: + if self.node.check_blockheight(): self.check(check_all=True) def get_user_folder_id(self, user=None): @@ -347,79 +220,7 @@ def delete_user(self, user): # mark @property def bitcoin_datadir(self): - return self.config_manager.bitcoin_datadir - - def abortrescanutxo(self): - self.rpc.scantxoutset("abort", []) - # Bitcoin Core doesn't catch up right away - # so app.specter.check() doesn't work - self._info["utxorescan"] = None - self.utxorescanwallet = None - - def test_rpc(self, **kwargs): - conf = copy.deepcopy(self.config_manager.data["rpc"]) - conf.update(kwargs) - - rpc = get_rpc( - conf, - return_broken_instead_none=True, - proxy_url=self.proxy_url, - only_tor=self.only_tor, - ) - if rpc is None: - return {"out": "", "err": "autodetect failed", "code": -1} - r = {} - r["tests"] = {"connectable": False} - r["err"] = "" - r["code"] = 0 - try: - r["tests"]["recent_version"] = ( - int(rpc.getnetworkinfo()["version"]) >= 170000 - ) - if not r["tests"]["recent_version"]: - r["err"] = "Core Node might be too old" - - r["tests"]["connectable"] = True - r["tests"]["credentials"] = True - try: - rpc.listwallets() - r["tests"]["wallets"] = True - except RpcError as rpce: - logger.error(rpce) - r["tests"]["wallets"] = False - r["err"] = "Wallets disabled" - - r["out"] = json.dumps(rpc.getblockchaininfo(), indent=4) - except ConnectionError as e: - logger.error("Caught an ConnectionError while test_rpc: %s", e) - - r["tests"]["connectable"] = False - r["err"] = "Failed to connect!" - r["code"] = -1 - except RpcError as rpce: - logger.error("Caught an RpcError while test_rpc: %s", rpce) - logger.error(rpce.status_code) - r["tests"]["connectable"] = True - r["code"] = rpc.r.status_code - if rpce.status_code == 401: - r["tests"]["credentials"] = False - r["err"] = "RPC authentication failed!" - else: - r["err"] = str(rpce.status_code) - except Exception as e: - logger.error( - "Caught an exception of type {} while test_rpc: {}".format( - type(e), str(e) - ) - ) - r["out"] = "" - if rpc.r is not None and "error" in rpc.r: - r["err"] = rpc.r["error"] - r["code"] = rpc.r.status_code - else: - r["err"] = "Failed to connect" - r["code"] = -1 - return r + return self.node.datadir # mark def _save(self): @@ -430,23 +231,11 @@ def config_fname(self): return os.path.join(self.data_folder, "config.json") # mark - def update_rpc(self, **kwargs): - need_update = self.config_manager.update_rpc(**kwargs) - if need_update: - self.rpc = get_rpc( - self.config_manager.rpc_conf, - None, - proxy_url=self.proxy_url, - only_tor=self.only_tor, - ) - self._save() - self.check(check_all=True) - return self.rpc is not None - - # mark - def set_bitcoind_pid(self, pid): - """set the control pid of the bitcoind daemon""" - self.config_manager.set_bitcoind_pid(pid) + def update_active_node(self, node_alias): + """update the current active node to use""" + self.config_manager.update_active_node(node_alias) + self.node_manager.switch_node(node_alias) + self.check() def update_setup_status(self, software_name, stage): self.setup_status[software_name]["error"] = "" @@ -481,11 +270,6 @@ def get_setup_status(self, software_name): return {"installed": installed, **self.setup_status[software_name]} - # mark - def update_use_external_node(self, use_external_node): - """set whatever specter should connect to internal or external node""" - self.config_manager.update_use_external_node(use_external_node) - # mark def update_auth(self, method, rate_limit, registration_link_timeout): """simply persisting the current auth-choice""" @@ -562,28 +346,9 @@ def tor_daemon(self): "Tor daemon files missing. Make sure Tor is installed within Specter" ) - @property - def bitcoind(self): - if os.path.isfile(self.bitcoind_path): - if not self._bitcoind: - self._bitcoind = BitcoindPlainController( - bitcoind_path=self.bitcoind_path, - rpcport=8332, - network="mainnet", - rpcuser=self.config["internal_node"]["user"], - rpcpassword=self.config["internal_node"]["password"], - ) - return self._bitcoind - raise SpecterError( - "Bitcoin Core files missing. Make sure Bitcoin Core is installed within Specter" - ) - def is_tor_dameon_running(self): return self._tor_daemon and self._tor_daemon.is_running() - def is_bitcoind_running(self): - return self._bitcoind and self._bitcoind.check_existing() - @property def tor_controller(self): if self._tor_controller: @@ -649,41 +414,45 @@ def estimatesmartfee(self, blocks): return self.rpc.estimatesmartfee(blocks) @property - def is_running(self): - return self._is_running + def bitcoind_path(self): + bitcoind_path = os.path.join(self.data_folder, "bitcoin-binaries/bin/bitcoind") - @property - def is_configured(self): - return self._is_configured + if platform.system() == "Windows": + bitcoind_path += ".exe" + return bitcoind_path @property def info(self): - return self._info + return self.node.info @property def network_info(self): - return self._network_info + return self.node.network_info @property def bitcoin_core_version(self): - return self.network_info["subversion"].replace("/", "").replace("Satoshi:", "") + return self.node.bitcoin_core_version @property def bitcoin_core_version_raw(self): - return self.network_info["version"] + return self.node.bitcoin_core_version_raw @property def chain(self): - return self._info["chain"] + return self.node.chain @property def is_testnet(self): - return is_testnet(self.chain) + return self.node.is_testnet @property def user_config(self): return self.config if self.user.is_admin else self.user.config + @property + def active_node_alias(self): + return self.user_config.get("active_node_alias", "default") + @property def explorer(self): return self.user_config.get("explorers", {}).get(self.chain, "") @@ -796,6 +565,63 @@ def specter_backup_file(self): memory_file.seek(0) return memory_file + # Migrating RPC nodes from Specter 1.3.1 and lower (prior to the node manager) + def migrate_old_node_format(self): + if not os.path.isdir(os.path.join(self.data_folder, "nodes")): + os.mkdir(os.path.join(self.data_folder, "nodes")) + old_rpc = self.config.get("rpc", None) + old_internal_rpc = self.config.get("internal_node", None) + if old_internal_rpc and os.path.isfile(self.bitcoind_path): + internal_node = InternalNode( + "Specter Bitcoin", + "specter_bitcoin", + old_internal_rpc.get("autodetect", False), + old_internal_rpc.get("datadir", get_default_datadir()), + old_internal_rpc.get("user", ""), + old_internal_rpc.get("password", ""), + old_internal_rpc.get("port", 8332), + old_internal_rpc.get("host", "localhost"), + old_internal_rpc.get("protocol", "http"), + os.path.join( + os.path.join(self.data_folder, "nodes"), "specter_bitcoin.json" + ), + self, + self.bitcoind_path, + "mainnet", + self.internal_bitcoind_version, + ) + write_node( + internal_node, + os.path.join( + os.path.join(self.data_folder, "nodes"), "specter_bitcoin.json" + ), + ) + del self.config["internal_node"] + if not old_rpc or not old_rpc.get("external_node", True): + self.config_manager.update_active_node("specter_bitcoin") + + if old_rpc: + node = Node( + "Bitcoin Core", + "default", + old_rpc.get("autodetect", True), + old_rpc.get("datadir", get_default_datadir()), + old_rpc.get("user", ""), + old_rpc.get("password", ""), + old_rpc.get("port", None), + old_rpc.get("host", "localhost"), + old_rpc.get("protocol", "http"), + True, + os.path.join(os.path.join(self.data_folder, "nodes"), "default.json"), + self, + ) + write_node( + node, + os.path.join(os.path.join(self.data_folder, "nodes"), "default.json"), + ) + del self.config["rpc"] + self._save() + class SpecterConfiguration: """An abstract class which only holds functionality relevant for storage of information mostly diff --git a/src/cryptoadvance/specter/static/img/file_icon.svg b/src/cryptoadvance/specter/static/img/file_icon.svg deleted file mode 100644 index 88c4705e6b..0000000000 --- a/src/cryptoadvance/specter/static/img/file_icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/cryptoadvance/specter/static/img/flip-horizontal.svg b/src/cryptoadvance/specter/static/img/flip-horizontal.svg new file mode 100644 index 0000000000..609309958e --- /dev/null +++ b/src/cryptoadvance/specter/static/img/flip-horizontal.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/cryptoadvance/specter/templates/device/new_device_manual.jinja b/src/cryptoadvance/specter/templates/device/new_device_manual.jinja index 5d202b8244..8708a6338d 100644 --- a/src/cryptoadvance/specter/templates/device/new_device_manual.jinja +++ b/src/cryptoadvance/specter/templates/device/new_device_manual.jinja @@ -12,7 +12,7 @@
{% if not device %} -