Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: global config sepc #1725

Merged
merged 11 commits into from
Jan 20, 2021
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/commands/build/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/commands/deploy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/commands/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/commands/logout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
2 changes: 1 addition & 1 deletion src/commands/status/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.`)
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/lib/fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -39,6 +41,8 @@ module.exports = {
readFileAsync,
readFileAsyncCatchError,
writeFileAsync,
rmFileAsync,
copyFileAsync,
fileExistsAsync,
isFileAsync,
mkdirRecursiveSync,
Expand Down
13 changes: 10 additions & 3 deletions src/lib/settings.js
Original file line number Diff line number Diff line change
@@ -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
}

Expand All @@ -15,4 +22,4 @@ const getPathInProject = (paths) => {
return pathInProject
}

module.exports = { getPathInHome, getPathInProject }
module.exports = { getLegacyPathInHome, getPathInHome, getPathInProject }
15 changes: 9 additions & 6 deletions src/utils/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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']
Expand All @@ -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) {
Expand All @@ -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)
Expand All @@ -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),
Expand Down Expand Up @@ -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
}
Expand Down
32 changes: 32 additions & 0 deletions src/utils/get-global-config.js
Original file line number Diff line number Diff line change
@@ -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 () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[sand] Not sure there is big benefit for it, but would it make sense to use lodash.once to resolve the config only a single time.
For example in utils/command.js the function will be invoked twice (once for the token and once for the whole config).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! Good one, I actually tested it out locally and saw some specifc commands could actually trigger a call to globalConfig multiple times, I wanted to mention that but just forgot 🀦 I think some kind of memoisation like lodash.once would definitely be a good thing here πŸ‘

Copy link
Contributor

@ehmicky ehmicky Jan 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small suggestion: memoize-one is also an option. It does the same thing but the code looks much clearer. Lodash code always looks so verbose :/

lodash.once():

var FUNC_ERROR_TEXT = 'Expected a function';

var INFINITY = 1 / 0,
    MAX_INTEGER = 1.7976931348623157e+308,
    NAN = 0 / 0;

var symbolTag = '[object Symbol]';

var reTrim = /^\s+|\s+$/g;

var reIsBadHex = /^[-+]0x[0-9a-f]+$/i;

var reIsBinary = /^0b[01]+$/i;

var reIsOctal = /^0o[0-7]+$/i;

var freeParseInt = parseInt;

var objectProto = Object.prototype;

var objectToString = objectProto.toString;

function before(n, func) {
  var result;
  if (typeof func != 'function') {
    throw new TypeError(FUNC_ERROR_TEXT);
  }
  n = toInteger(n);
  return function() {
    if (--n > 0) {
      result = func.apply(this, arguments);
    }
    if (n <= 1) {
      func = undefined;
    }
    return result;
  };
}

function once(func) {
  return before(2, func);
}

function isObject(value) {
  var type = typeof value;
  return !!value && (type == 'object' || type == 'function');
}

function isObjectLike(value) {
  return !!value && typeof value == 'object';
}

function isSymbol(value) {
  return typeof value == 'symbol' ||
    (isObjectLike(value) && objectToString.call(value) == symbolTag);
}

function toFinite(value) {
  if (!value) {
    return value === 0 ? value : 0;
  }
  value = toNumber(value);
  if (value === INFINITY || value === -INFINITY) {
    var sign = (value < 0 ? -1 : 1);
    return sign * MAX_INTEGER;
  }
  return value === value ? value : 0;
}

function toInteger(value) {
  var result = toFinite(value),
      remainder = result % 1;

  return result === result ? (remainder ? result - remainder : result) : 0;
}

function toNumber(value) {
  if (typeof value == 'number') {
    return value;
  }
  if (isSymbol(value)) {
    return NAN;
  }
  if (isObject(value)) {
    var other = typeof value.valueOf == 'function' ? value.valueOf() : value;
    value = isObject(other) ? (other + '') : other;
  }
  if (typeof value != 'string') {
    return value === 0 ? value : +value;
  }
  value = value.replace(reTrim, '');
  var isBinary = reIsBinary.test(value);
  return (isBinary || reIsOctal.test(value))
    ? freeParseInt(value.slice(2), isBinary ? 2 : 8)
    : (reIsBadHex.test(value) ? NAN : +value);
}

module.exports = once;

memoize-one:

  function areInputsEqual(newInputs, lastInputs) {
      if (newInputs.length !== lastInputs.length) {
          return false;
      }
      for (var i = 0; i < newInputs.length; i++) {
          if (newInputs[i] !== lastInputs[i]) {
              return false;
          }
      }
      return true;
  }

  function memoizeOne(resultFn, isEqual) {
      if (isEqual === void 0) { isEqual = areInputsEqual; }
      var lastThis;
      var lastArgs = [];
      var lastResult;
      var calledOnce = false;
      function memoized() {
          var newArgs = [];
          for (var _i = 0; _i < arguments.length; _i++) {
              newArgs[_i] = arguments[_i];
          }
          if (calledOnce && lastThis === this && isEqual(newArgs, lastArgs)) {
              return lastResult;
          }
          lastResult = resultFn.apply(this, newArgs);
          calledOnce = true;
          lastThis = this;
          lastArgs = newArgs;
          return lastResult;
      }
      return memoized;
  }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point @ehmicky! So I was actually looking at lodash.once and was considering alternatives, however seems like we already have lodash as a dependency - https://github.com/netlify/cli/blob/master/package.json#L132 - taking that into account would it be worth bringing another dependency? Might be something worth considering if we plan on decoupling lodash from the project (or at least shift to depending only on the specific lodash modules we need).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point!
Either works, your call πŸ‘

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given we're keeping the "legacy" config path for now, would it be worth it creating an issue just to track this "tech debt"? CC @erezrokah

Copy link
Contributor

@ehmicky ehmicky Jan 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! πŸ‘
About adding an issue for future dead code removal: this would be a good idea.

Quick question: was there any way to use this.netlify.globalConfig instead of calling getGlobalConfig() in the telemetry and hooks code? This would remove the need to memoize. Feel free to discard this comment if not relevant, I was just curious :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch @ehmicky. I guess it would be possible and it would probably make a lot of sense for the track/identify telemetry functions to receive the required config via its parameters instead of requiring it. For the hooks however, I'm still digging through oclifs arch but seems like they don't have access to the base command instance - https://oclif.io/docs/hooks#lifecycle-events πŸ˜•. We also have this static getToken function that depends on the global-config - https://github.com/netlify/cli/blob/master/src/utils/command.js#L313 - but seems like it's only being called on the deploy test - https://github.com/netlify/cli/blob/master/tests/command.deploy.test.js#L8 (?)

Tbh I think it makes sense to avoid this memoisation πŸ€” maybe eventually remove it. Just not 100% of what would be the impact of doing it right now, given the global-config seems to be tied to a couple of different places.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could move forward with memoisation and open another issue to refactor the code to use this.netlify.globalConfig.
WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good! πŸ‘

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)
76 changes: 76 additions & 0 deletions src/utils/get-global-config.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
17 changes: 0 additions & 17 deletions src/utils/global-config.js

This file was deleted.

8 changes: 5 additions & 3 deletions src/utils/telemetry/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -45,7 +45,7 @@ const eventConfig = {
],
}

const track = function (eventName, payload) {
const track = async function (eventName, payload) {
const properties = payload || {}

if (IS_INSIDE_CI) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion tests/command.deploy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
{
Expand Down