diff --git a/components/yaml.js b/components/yaml.js index 43037be82..26449a692 100644 --- a/components/yaml.js +++ b/components/yaml.js @@ -1,5 +1,6 @@ 'use strict'; +const exists = require('../utils/exists-sync'); const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); @@ -43,7 +44,7 @@ const fileloader = { if (!path.isAbsolute(input.file)) input.file = findFile(input.file, this.base); // Otherwise check the path exists - return fs.existsSync(input.file); + return exists(input.file); }, construct: function(data) { // transform data diff --git a/hooks/lando-copy-v3-scripts.js b/hooks/lando-copy-v3-scripts.js index e378e352c..8e17ca71d 100644 --- a/hooks/lando-copy-v3-scripts.js +++ b/hooks/lando-copy-v3-scripts.js @@ -3,9 +3,12 @@ const fs = require('fs'); const path = require('path'); +const exists = require('../utils/exists-sync'); + module.exports = async lando => { return lando.Promise.map(lando.config.plugins, plugin => { - if (fs.existsSync(plugin.scripts)) { + // @NOTE: plugin.scripts is undefined for plugins that do not ship scripts + if (exists(plugin.scripts)) { const confDir = path.join(lando.config.userConfRoot, 'scripts'); const dest = require('../utils/move-config')(plugin.scripts, confDir); require('../utils/make-executable')(fs.readdirSync(dest), dest); diff --git a/hooks/lando-generate-tasks-cache.js b/hooks/lando-generate-tasks-cache.js index 728553d5b..91b371e33 100644 --- a/hooks/lando-generate-tasks-cache.js +++ b/hooks/lando-generate-tasks-cache.js @@ -4,6 +4,8 @@ const _ = require('lodash'); const fs = require('fs'); const path = require('path'); +const exists = require('../utils/exists-sync'); + module.exports = async lando => { // load in legacy inits await require('./lando-load-legacy-inits')(lando); @@ -11,7 +13,7 @@ module.exports = async lando => { // build the cache return lando.Promise.resolve(lando.config.plugins) // Make sure the tasks dir exists - .filter(plugin => fs.existsSync(plugin.tasks)) + .filter(plugin => exists(plugin.tasks)) // Get a list off full js files that exist in that dir .map(plugin => _(fs.readdirSync(plugin.tasks)) .map(file => path.join(plugin.tasks, file)) diff --git a/hooks/lando-load-legacy-inits.js b/hooks/lando-load-legacy-inits.js index 14eab002e..3abd37348 100644 --- a/hooks/lando-load-legacy-inits.js +++ b/hooks/lando-load-legacy-inits.js @@ -5,23 +5,27 @@ const fs = require('fs'); const glob = require('glob'); const path = require('path'); +// @NOTE: dirs are plucked off of the plugin objects so they are undefined for any plugin that +// does not have that particular dir, hence the permissive exists check +const exists = require('../utils/exists-sync'); + // Helper to get init config const getLegacyInitConfig = dirs => _(dirs) - .filter(dir => fs.existsSync(dir)) + .filter(dir => exists(dir)) .flatMap(dir => glob.sync(path.join(dir, '*', 'init.js'))) .map(file => require(file)) .value(); // Helper to get init config const getInitConfig = dirs => _(dirs) - .filter(dir => fs.existsSync(dir)) + .filter(dir => exists(dir)) .flatMap(dir => fs.readdirSync(dir).map(file => path.join(dir, file))) .map(file => require(file)) .value(); // Helper to get init source config const getInitSourceConfig = dirs => _(dirs) - .filter(dir => fs.existsSync(dir)) + .filter(dir => exists(dir)) .flatMap(dir => glob.sync(path.join(dir, '*.js'))) .map(file => require(file)) .flatMap(source => source.sources) diff --git a/lib/docker.js b/lib/docker.js index c38325471..a061316fa 100644 --- a/lib/docker.js +++ b/lib/docker.js @@ -3,7 +3,7 @@ // Modules const _ = require('lodash'); const Dockerode = require('dockerode'); -const fs = require('fs'); +const exists = require('../utils/exists-sync'); const Promise = require('./promise'); /* @@ -16,7 +16,7 @@ const containerOpt = (container, method, message, opts = {}) => container[method /* * Helper to determine files exists in an array of files */ -const srcExists = (files = []) => _.reduce(files, (exists, file) => fs.existsSync(file) || exists, false); +const srcExists = (files = []) => _.reduce(files, (found, file) => exists(file) || found, false); /* * Creates a new yaml instance. diff --git a/lib/engine.js b/lib/engine.js index b86c919b4..79abbc919 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -2,7 +2,7 @@ // Modules const _ = require('lodash'); -const fs = require('fs'); +const exists = require('../utils/exists-sync'); const LandoDaemon = require('./daemon'); const Landerode = require('./docker'); const router = require('./router'); @@ -24,7 +24,7 @@ module.exports = class Engine { run, ); // Determine install status - this.composeInstalled = fs.existsSync(config.orchestratorBin); + this.composeInstalled = exists(config.orchestratorBin); this.dockerInstalled = this.daemon.docker !== false; // set the compose separator diff --git a/lib/lando.js b/lib/lando.js index 3403e5c08..7f51f0227 100644 --- a/lib/lando.js +++ b/lib/lando.js @@ -1,6 +1,7 @@ 'use strict'; const _ = require('lodash'); +const exists = require('../utils/exists-sync'); const fs = require('fs'); const glob = require('glob'); const path = require('path'); @@ -136,7 +137,7 @@ const bootstrapApp = lando => { // start with legacy builder discovery const legacyBuilders = _(['compose', 'types', 'services', 'recipes']) .flatMap(type => _.map(lando.config.plugins, plugin => plugin[type])) - .filter(dir => fs.existsSync(dir)) + .filter(dir => exists(dir)) .flatMap(dir => glob.sync(path.join(dir, '*', 'builder.js'))) .map(file => lando.factory.add(file).name) .value(); @@ -145,7 +146,7 @@ const bootstrapApp = lando => { // then move to legacy builders we can lazy load from builders const legacyItems = _(['builders']) .flatMap(type => _.map(lando.config.plugins, plugin => plugin[type])) - .filter(dir => fs.existsSync(dir)) + .filter(dir => exists(dir)) .flatMap(dir => fs.readdirSync(dir).map(file => path.join(dir, file))) .map(file => lando.factory.add(file)) .value(); diff --git a/lib/utils.js b/lib/utils.js index 9f9a80828..e224129f7 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -109,6 +109,7 @@ module.exports = { // these are new and useful v4 thing debugShim: (...args) => require('../utils/debug-shim')(...args), downloadX: (...args) => require('../utils/download-x')(...args), + existsSync: (...args) => require('../utils/exists-sync')(...args), getAxios: (...args) => require('../utils/get-axios')(...args), getJsYaml: () => require('js-yaml'), getLodash: () => require('lodash'), diff --git a/test/exists-sync.spec.js b/test/exists-sync.spec.js new file mode 100644 index 000000000..9ff4142f0 --- /dev/null +++ b/test/exists-sync.spec.js @@ -0,0 +1,71 @@ +/* + * Tests for utils/exists-sync. + * @file exists-sync.spec.js + */ + +'use strict'; + +// Setup chai. +const chai = require('chai'); +const expect = chai.expect; +chai.should(); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const {execFileSync} = require('child_process'); + +// Get the module to test +const exists = require('../utils/exists-sync'); + +// node >=24 emits DEP0187 when fs.existsSync gets a non path-like arg +const isNode24 = Number(process.versions.node.split('.')[0]) >= 24; + +// helper to run a snippet in a child process with deprecations promoted to throws +// @NOTE: node only emits a given deprecation once per process so we cannot reliably assert on +// this from inside the shared mocha process +const runStrict = code => execFileSync(process.execPath, ['--throw-deprecation', '-e', code], { + cwd: path.resolve(__dirname, '..'), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], +}); + +describe('exists-sync', () => { + const realFile = path.join(os.tmpdir(), 'lando-exists-sync-test.txt'); + + before(() => fs.writeFileSync(realFile, 'lando')); + after(() => fs.rmSync(realFile, {force: true})); + + it('should return false for non path-like values instead of throwing or warning', () => { + const invalids = [undefined, null, {}, {file: '/tmp'}, [], ['/tmp'], 42, true, false, NaN, () => {}]; + for (const invalid of invalids) { + expect(exists(invalid), `expected false for ${String(invalid)}`).to.equal(false); + } + }); + + it('should behave like fs.existsSync for path-like values', () => { + expect(exists(realFile)).to.equal(true); + expect(exists(Buffer.from(realFile))).to.equal(true); + expect(exists(new URL(`file://${realFile}`))).to.equal(true); + + const missing = path.join(os.tmpdir(), 'lando-exists-sync-nope.txt'); + expect(exists(missing)).to.equal(false); + expect(exists(Buffer.from(missing))).to.equal(false); + expect(exists(new URL(`file://${missing}`))).to.equal(false); + }); + + it('should not emit a DEP0187 deprecation warning for invalid values', () => { + const code = ` + const exists = require('./utils/exists-sync'); + for (const bad of [undefined, null, {}, [], 42, true]) { + if (exists(bad) !== false) throw new Error('expected false for ' + String(bad)); + } + `; + expect(() => runStrict(code)).to.not.throw(); + }); + + // this guards the guard, if node ever stops warning here the test above stops proving anything + (isNode24 ? it : it.skip)('should be verifiably avoiding a real fs.existsSync deprecation', () => { + expect(() => runStrict(`require('fs').existsSync(undefined)`)).to.throw(/DeprecationWarning/); + }); +}); diff --git a/utils/exists-sync.js b/utils/exists-sync.js new file mode 100644 index 000000000..cac4e4960 --- /dev/null +++ b/utils/exists-sync.js @@ -0,0 +1,21 @@ +'use strict'; + +const fs = require('fs'); + +/** + * Permissive fs.existsSync(). + * + * Node >=24 emits a DEP0187 deprecation warning when fs.existsSync() is handed anything that is + * not a string, Buffer or URL. Lando has a bunch of call sites that are legitimately permissive + * eg they pluck optional keys off of plugin/config objects and just want a "is there a file + * there?" answer. This restores the pre-24 behavior of quietly returning false for those. + * + * @param {*} file - The thing to check, may be anything + * @return {boolean} Whether file is a path that exists + */ +module.exports = file => { + // bail on anything fs.existsSync() would warn about + if (typeof file !== 'string' && !Buffer.isBuffer(file) && !(file instanceof URL)) return false; + // otherwise defer to node + return fs.existsSync(file); +}; diff --git a/utils/get-passphraseless-keys.js b/utils/get-passphraseless-keys.js index f6d5d47b9..038118b69 100644 --- a/utils/get-passphraseless-keys.js +++ b/utils/get-passphraseless-keys.js @@ -1,5 +1,6 @@ 'use strict'; +const exists = require('./exists-sync'); const fs = require('fs'); const path = require('path'); const read = require('./read-file'); @@ -37,7 +38,8 @@ module.exports = (paths = []) => { // now lets try to find all the private keys without passphrases return paths - .filter(path => fs.existsSync(path)) + // @NOTE: paths comes from user config so it can contain basically anything + .filter(path => exists(path)) .map(path => fs.statSync(path).isDirectory() ? getAllFiles(path) : path) .flat(Number.POSITIVE_INFINITY) .map(file => ({file, contents: read(file)})) diff --git a/utils/get-plugin-config.js b/utils/get-plugin-config.js index ac0d07086..37c59a936 100644 --- a/utils/get-plugin-config.js +++ b/utils/get-plugin-config.js @@ -1,12 +1,12 @@ 'use strict'; -const fs = require('fs'); +const exists = require('./exists-sync'); const merge = require('lodash/merge'); const read = require('./read-file'); module.exports = (file, config = {}) => { // if config file exists then rebase config on top of it - if (fs.existsSync(file)) return merge({}, read(file), config); + if (exists(file)) return merge({}, read(file), config); // otherwise return config alone return config; }; diff --git a/utils/get-tasks.js b/utils/get-tasks.js index 3ae593808..7ba280199 100644 --- a/utils/get-tasks.js +++ b/utils/get-tasks.js @@ -4,6 +4,9 @@ const _ = require('lodash'); const fs = require('fs'); const path = require('path'); +// @NOTE: the various caches below are all optional config keys so they may be undefined +const exists = require('./exists-sync'); + /* * Paths to / */ @@ -21,7 +24,7 @@ const pathsToRoot = (startFrom = process.cwd()) => { const getBsLevel = (config, command) => { if (_.has(config, `tooling.${command}.level`)) return config.tooling[command].level; else if (_.find(config.tooling, {id: command}).level) return _.find(config.tooling, {id: command}).level; - else return (!fs.existsSync(config.composeCache)) ? 'app' : 'engine'; + else return (!exists(config.composeCache)) ? 'app' : 'engine'; }; /* @@ -99,12 +102,12 @@ const engineRunner = (config, command) => (argv, lando) => { module.exports = (config = {}, argv = {}, tasks = []) => { // merge in recipe cache config first - if (fs.existsSync(config.recipeCache) && _.has(config, 'recipe')) { + if (exists(config.recipeCache) && _.has(config, 'recipe')) { config = _.merge({}, JSON.parse(fs.readFileSync(config.recipeCache, {encoding: 'utf-8'})), config); } // If we have a tooling router lets rebase on that - if (fs.existsSync(config.toolingRouter)) { + if (exists(config.toolingRouter)) { // Get the closest route const closestRoute = _(loadCacheFile(config.toolingRouter)) .map(route => _.merge({}, route, { @@ -153,7 +156,7 @@ module.exports = (config = {}, argv = {}, tasks = []) => { const coreTasks = _(loadCacheFile(process.landoTaskCacheFile)).map(t => ([t.command, t])).fromPairs().value(); // mix in any relevant compose cache things - if (fs.existsSync(config.composeCache)) { + if (exists(config.composeCache)) { try { const composeCache = JSON.parse(fs.readFileSync(config.composeCache, {encoding: 'utf-8'})); diff --git a/utils/load-config-files.js b/utils/load-config-files.js index 923291de2..819172b39 100644 --- a/utils/load-config-files.js +++ b/utils/load-config-files.js @@ -5,6 +5,8 @@ const fs = require('fs'); const path = require('path'); const yaml = require('../components/yaml'); +const exists = require('./exists-sync'); + /* * @TODO */ @@ -49,7 +51,7 @@ const normalizePlugins = (plugins = [], baseDir = __dirname) => _(plugins) module.exports = files => _(files) // Filter the source out if it doesn't exist - .filter(source => fs.existsSync(source) || fs.existsSync(source.file)) + .filter(source => exists(source) || exists(source.file)) // If the file is just a string lets map it to an object .map(source => { return _.isString(source) ? {file: source, data: yaml.load(fs.readFileSync(source)) || {}} : source; diff --git a/utils/load-file.js b/utils/load-file.js index 3d6a34614..69b9f50ef 100644 --- a/utils/load-file.js +++ b/utils/load-file.js @@ -1,10 +1,10 @@ 'use strict'; -const fs = require('fs'); +const exists = require('./exists-sync'); module.exports = file => { // if the file doesnt exist then return an empty object - if (!fs.existsSync(file)) return {}; + if (!exists(file)) return {}; // otherwise load the file and return it return require('./read-file')(file); };