diff --git a/package-lock.json b/package-lock.json index 6149f3b1865..3332dea28c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7781,6 +7781,11 @@ "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", "dev": true }, + "env-paths": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.0.tgz", + "integrity": "sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA==" + }, "envinfo": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.7.3.tgz", diff --git a/package.json b/package.json index 03f3595e57f..62822583559 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "del": "^5.1.0", "dot-prop": "^5.1.0", "dotenv": "^8.2.0", + "env-paths": "^2.2.0", "envinfo": "^7.3.1", "execa": "^5.0.0", "express": "^4.17.1", @@ -129,6 +130,7 @@ "lodash": "^4.17.20", "log-symbols": "^3.0.0", "make-dir": "^3.0.0", + "memoize-one": "^5.1.1", "minimist": "^1.2.5", "multiparty": "^4.2.1", "netlify": "^6.0.0", diff --git a/src/commands/build/index.js b/src/commands/build/index.js index 84f8fabf2b3..77cd5e27780 100644 --- a/src/commands/build/index.js +++ b/src/commands/build/index.js @@ -9,9 +9,10 @@ class BuildCommand extends Command { // Run Netlify Build async run() { // Retrieve Netlify Build options + const [token] = await this.getConfigToken() const options = await getBuildOptions({ context: this, - token: this.getConfigToken()[0], + token, flags: this.parse(BuildCommand).flags, }) this.checkOptions(options) diff --git a/src/commands/deploy.js b/src/commands/deploy.js index eb20197f04f..0beb0d10bb7 100644 --- a/src/commands/deploy.js +++ b/src/commands/deploy.js @@ -411,9 +411,10 @@ class DeployCommand extends Command { } if (flags.build) { + const [token] = await this.getConfigToken() const options = await getBuildOptions({ context: this, - token: this.getConfigToken()[0], + token, flags, }) const exitCode = await runBuild(options) diff --git a/src/commands/login.js b/src/commands/login.js index cd0d1c50a7c..8562f138bee 100644 --- a/src/commands/login.js +++ b/src/commands/login.js @@ -6,7 +6,7 @@ const Command = require('../utils/command') class LoginCommand extends Command { async run() { const { flags } = this.parse(LoginCommand) - const [accessToken, location] = this.getConfigToken() + const [accessToken, location] = await this.getConfigToken() if (accessToken && !flags.new) { this.log(`Already logged in ${msg(location)}`) this.log() diff --git a/src/commands/logout.js b/src/commands/logout.js index c40937505e4..7d0b7b977a7 100644 --- a/src/commands/logout.js +++ b/src/commands/logout.js @@ -3,7 +3,7 @@ const { track } = require('../utils/telemetry') class LogoutCommand extends Command { async run() { - const [accessToken, location] = this.getConfigToken() + const [accessToken, location] = await this.getConfigToken() if (!accessToken) { this.log(`Already logged out`) diff --git a/src/commands/status/index.js b/src/commands/status/index.js index 76d354bb364..96478acd47d 100644 --- a/src/commands/status/index.js +++ b/src/commands/status/index.js @@ -12,7 +12,7 @@ class StatusCommand extends Command { const { flags } = this.parse(StatusCommand) const current = globalConfig.get('userId') - const [accessToken] = this.getConfigToken() + const [accessToken] = await this.getConfigToken() if (!accessToken) { this.log(`Not logged in. Please log in to see site status.`) diff --git a/src/hooks/init.js b/src/hooks/init.js index 65e7c2584d5..38d6f9949a6 100644 --- a/src/hooks/init.js +++ b/src/hooks/init.js @@ -2,11 +2,12 @@ const process = require('process') const envinfo = require('envinfo') -const globalConfig = require('../utils/global-config') +const getGlobalConfig = require('../utils/get-global-config') const header = require('../utils/header') const { track } = require('../utils/telemetry') module.exports = async function initHooks(context) { + const globalConfig = await getGlobalConfig() // Enable/disable telemetry Global flags. TODO refactor where these fire if (context.id === '--telemetry-disable') { globalConfig.set('telemetryDisabled', true) diff --git a/src/lib/fs.js b/src/lib/fs.js index b6758767047..3e3e4d32931 100644 --- a/src/lib/fs.js +++ b/src/lib/fs.js @@ -8,6 +8,8 @@ const pathType = require('path-type') const statAsync = promisify(fs.stat) const readFileAsync = promisify(fs.readFile) const writeFileAsync = promisify(fs.writeFile) +const rmFileAsync = promisify(fs.unlink) +const copyFileAsync = promisify(fs.copyFile) const accessAsync = promisify(fs.access) const readFileAsyncCatchError = async (filepath) => { @@ -39,6 +41,8 @@ module.exports = { readFileAsync, readFileAsyncCatchError, writeFileAsync, + rmFileAsync, + copyFileAsync, fileExistsAsync, isFileAsync, mkdirRecursiveSync, diff --git a/src/lib/settings.js b/src/lib/settings.js index 9ac03f4c16f..608b52cd45d 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -1,12 +1,19 @@ const os = require('os') const path = require('path') +const envPaths = require('env-paths') + +const OSBasedPaths = envPaths('netlify', { suffix: '' }) const NETLIFY_HOME = '.netlify' -const getHomeDirectory = () => path.join(os.homedir(), NETLIFY_HOME) +// Deprecated method to get netlify's home config - ~/.netlify/... +const getLegacyPathInHome = (paths) => { + const pathInHome = path.join(os.homedir(), NETLIFY_HOME, ...paths) + return pathInHome +} const getPathInHome = (paths) => { - const pathInHome = path.join(getHomeDirectory(), ...paths) + const pathInHome = path.join(OSBasedPaths.config, ...paths) return pathInHome } @@ -15,4 +22,4 @@ const getPathInProject = (paths) => { return pathInProject } -module.exports = { getPathInHome, getPathInProject } +module.exports = { getLegacyPathInHome, getPathInHome, getPathInProject } diff --git a/src/utils/command.js b/src/utils/command.js index 9bf277daee7..df0b931590d 100644 --- a/src/utils/command.js +++ b/src/utils/command.js @@ -13,7 +13,7 @@ const { warnOnNetlifyDir } = require('../lib/deprecations') const { getAgent } = require('../lib/http-agent') const chalkInstance = require('./chalk') -const globalConfig = require('./global-config') +const getGlobalConfig = require('./get-global-config') const openBrowser = require('./open-browser') const StateConfig = require('./state-config') const { track, identify } = require('./telemetry') @@ -30,7 +30,7 @@ const isDefaultJson = () => argv._[0] === 'functions:invoke' || (argv._[0] === ' const isBuildCommand = () => argv._[0] === 'build' || (argv._[0] === 'deploy' && argv.build === true) -const getToken = (tokenFromFlag) => { +const getToken = async (tokenFromFlag) => { // 1. First honor command flag --auth if (tokenFromFlag) { return [tokenFromFlag, 'flag'] @@ -40,6 +40,7 @@ const getToken = (tokenFromFlag) => { return [NETLIFY_AUTH_TOKEN, 'env'] } // 3. If no env var use global user setting + const globalConfig = await getGlobalConfig() const userId = globalConfig.get('userId') const tokenFromConfig = globalConfig.get(`users.${userId}.auth.token`) if (tokenFromConfig) { @@ -55,7 +56,7 @@ class BaseCommand extends Command { // Grab netlify API token const authViaFlag = getAuthArg(argv) - const [token] = this.getConfigToken(authViaFlag) + const [token] = await this.getConfigToken(authViaFlag) // Get site id & build state const state = new StateConfig(cwd) @@ -78,6 +79,8 @@ class BaseCommand extends Command { apiOpts.pathPrefix = NETLIFY_API_URL === `${apiUrl.protocol}//${apiUrl.host}` ? '/api/v1' : apiUrl.pathname } + const globalConfig = await getGlobalConfig() + this.netlify = { // api methods api: new API(token || '', apiOpts), @@ -205,14 +208,14 @@ class BaseCommand extends Command { /** * Get user netlify API token * @param {string} - [tokenFromFlag] - value passed in by CLI flag - * @return {[string, string]} - tokenValue & location of resolved Netlify API token + * @return {Promise<[string, string]>} - Promise containing tokenValue & location of resolved Netlify API token */ getConfigToken(tokenFromFlag) { return getToken(tokenFromFlag) } - authenticate(tokenFromFlag) { - const [token] = this.getConfigToken(tokenFromFlag) + async authenticate(tokenFromFlag) { + const [token] = await this.getConfigToken(tokenFromFlag) if (token) { return token } diff --git a/src/utils/get-global-config.js b/src/utils/get-global-config.js new file mode 100644 index 00000000000..d1521e6a787 --- /dev/null +++ b/src/utils/get-global-config.js @@ -0,0 +1,32 @@ +const Configstore = require('configstore') +const memoizeOne = require('memoize-one') +const { v4: uuidv4 } = require('uuid') + +const { readFileAsync } = require('../lib/fs') +const { getPathInHome, getLegacyPathInHome } = require('../lib/settings') + +const globalConfigDefaults = { + /* disable stats from being sent to Netlify */ + telemetryDisabled: false, + /* cliId */ + cliId: uuidv4(), +} + +const getGlobalConfig = async function () { + const configPath = getPathInHome(['config.json']) + // Legacy config file in home ~/.netlify/config.json + const legacyPath = getLegacyPathInHome(['config.json']) + let legacyConfig + // Read legacy config if exists + try { + legacyConfig = JSON.parse(await readFileAsync(legacyPath)) + } catch (_) {} + // Use legacy config as default values + const defaults = { ...globalConfigDefaults, ...legacyConfig } + const configStore = new Configstore(null, defaults, { configPath }) + + return configStore +} + +// Memoise config result so that we only load it once +module.exports = memoizeOne(getGlobalConfig) diff --git a/src/utils/get-global-config.test.js b/src/utils/get-global-config.test.js new file mode 100644 index 00000000000..f3a276ef89a --- /dev/null +++ b/src/utils/get-global-config.test.js @@ -0,0 +1,76 @@ +const os = require('os') +const path = require('path') + +const test = require('ava') + +const { + rmdirRecursiveAsync, + mkdirRecursiveAsync, + readFileAsync, + writeFileAsync, + copyFileAsync, + rmFileAsync, +} = require('../lib/fs') +const { getPathInHome, getLegacyPathInHome } = require('../lib/settings') + +const getGlobalConfig = require('./get-global-config.js') + +const configPath = getPathInHome(['config.json']) +const legacyConfigPath = getLegacyPathInHome(['config.json']) +const tmpConfigBackupPath = path.join(os.tmpdir(), `netlify-config-backup-${Date.now()}`) + +test.before('backup current user config if exists', async () => { + try { + await copyFileAsync(configPath, tmpConfigBackupPath) + } catch (_) {} +}) + +test.after.always('cleanup tmp directory and legacy config', async () => { + try { + // Restore user config if exists + await mkdirRecursiveAsync(getPathInHome([])) + await copyFileAsync(tmpConfigBackupPath, configPath) + // Remove tmp backup if exists + await rmFileAsync(tmpConfigBackupPath) + } catch (_) {} + // Remove legacy config path + await rmdirRecursiveAsync(getLegacyPathInHome([])) +}) + +test.beforeEach('recreate clean config directories', async () => { + // Remove config dirs + await rmdirRecursiveAsync(getPathInHome([])) + await rmdirRecursiveAsync(getLegacyPathInHome([])) + // Make config dirs + await mkdirRecursiveAsync(getPathInHome([])) + await mkdirRecursiveAsync(getLegacyPathInHome([])) +}) + +// Not running tests in parallel as we're messing with the same config files + +test.serial('should use legacy config values as default if exists', async (t) => { + const legacyConfig = { someOldKey: 'someOldValue', overrideMe: 'oldValue' } + const newConfig = { overrideMe: 'newValue' } + await writeFileAsync(legacyConfigPath, JSON.stringify(legacyConfig)) + await writeFileAsync(configPath, JSON.stringify(newConfig)) + + const globalConfig = await getGlobalConfig() + t.is(globalConfig.get('someOldKey'), legacyConfig.someOldKey) + t.is(globalConfig.get('overrideMe'), newConfig.overrideMe) +}) + +test.serial('should not throw if legacy config is invalid JSON', async (t) => { + await writeFileAsync(legacyConfigPath, 'NotJson') + await t.notThrowsAsync(getGlobalConfig) +}) + +test.serial("should create config in netlify's config dir if none exists and store new values", async (t) => { + // Remove config dirs + await rmdirRecursiveAsync(getPathInHome([])) + await rmdirRecursiveAsync(getLegacyPathInHome([])) + + const globalConfig = await getGlobalConfig() + globalConfig.set('newProp', 'newValue') + const configFile = JSON.parse(await readFileAsync(configPath)) + t.deepEqual(globalConfig.all, configFile) +}) diff --git a/src/utils/global-config.js b/src/utils/global-config.js deleted file mode 100644 index 7f10ff4b36d..00000000000 --- a/src/utils/global-config.js +++ /dev/null @@ -1,17 +0,0 @@ -const Configstore = require('configstore') -const { v4: uuidv4 } = require('uuid') - -const { getPathInHome } = require('../lib/settings') - -const globalConfigDefaults = { - /* disable stats from being sent to Netlify */ - telemetryDisabled: false, - /* cliId */ - cliId: uuidv4(), -} - -const globalConfigOptions = { - configPath: getPathInHome(['config.json']), -} - -module.exports = new Configstore(null, globalConfigDefaults, globalConfigOptions) diff --git a/src/utils/telemetry/index.js b/src/utils/telemetry/index.js index c2d5acd0226..8a2a627d7ce 100644 --- a/src/utils/telemetry/index.js +++ b/src/utils/telemetry/index.js @@ -4,7 +4,7 @@ const process = require('process') const ci = require('ci-info') -const globalConfig = require('../global-config') +const getGlobalConfig = require('../get-global-config') const isValidEventName = require('./validation') @@ -45,7 +45,7 @@ const eventConfig = { ], } -const track = function (eventName, payload) { +const track = async function (eventName, payload) { const properties = payload || {} if (IS_INSIDE_CI) { @@ -55,6 +55,7 @@ const track = function (eventName, payload) { return Promise.resolve() } + const globalConfig = await getGlobalConfig() // exit early if tracking disabled const TELEMETRY_DISABLED = globalConfig.get('telemetryDisabled') if (TELEMETRY_DISABLED && !properties.force) { @@ -103,7 +104,7 @@ const track = function (eventName, payload) { return send('track', defaultData) } -const identify = function (payload) { +const identify = async function (payload) { const data = payload || {} if (IS_INSIDE_CI) { @@ -113,6 +114,7 @@ const identify = function (payload) { return Promise.resolve() } + const globalConfig = await getGlobalConfig() // exit early if tracking disabled const TELEMETRY_DISABLED = globalConfig.get('telemetryDisabled') if (TELEMETRY_DISABLED && !data.force) { diff --git a/tests/command.deploy.test.js b/tests/command.deploy.test.js index 9e162b0f6bd..5c0cc456c1b 100644 --- a/tests/command.deploy.test.js +++ b/tests/command.deploy.test.js @@ -134,7 +134,7 @@ if (process.env.IS_FORK !== 'true') { // validate edge handlers // use this until we can use `netlify api` - const [apiToken] = getToken() + const [apiToken] = await getToken() const { content_length: contentLength, ...rest } = await got( `https://api.netlify.com/api/v1/deploys/${deploy.deploy_id}/edge_handlers`, {