From 0453d619ca931bf844d2a6f6cb737cf3f529486c Mon Sep 17 00:00:00 2001 From: Lochlan Bunn Date: Fri, 12 Feb 2021 11:44:48 +1000 Subject: [PATCH] Add parent directories lookup support for .runtimeconfig.json --- package-lock.json | 6 +++ package.json | 1 + spec/config.spec.ts | 47 +++++++++++++++++++++- src/config.ts | 30 ++++++++++---- src/utilities/fs.ts | 97 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 src/utilities/fs.ts diff --git a/package-lock.json b/package-lock.json index f1e9dc457..0e0074c68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2825,6 +2825,12 @@ } } }, + "mock-fs": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-4.13.0.tgz", + "integrity": "sha512-DD0vOdofJdoaRNtnWcrXe6RQbpHkPPmtqGq14uRX0F8ZKJ5nv89CVTYl/BZdppDxBDaV0hl75htg3abpEWlPZA==", + "dev": true + }, "mock-require": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/mock-require/-/mock-require-3.0.3.tgz", diff --git a/package.json b/package.json index f6f9ae1c6..6a9c6093c 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "jsdom": "^16.2.1", "jsonwebtoken": "^8.5.1", "mocha": "^6.1.4", + "mock-fs": "^4.13.0", "mock-require": "^3.0.3", "mz": "^2.7.0", "nock": "^10.0.6", diff --git a/spec/config.spec.ts b/spec/config.spec.ts index b721dc3a5..c38f7a646 100644 --- a/spec/config.spec.ts +++ b/spec/config.spec.ts @@ -21,6 +21,7 @@ // SOFTWARE. import { expect } from 'chai'; +import * as mockFs from 'mock-fs'; import * as mockRequire from 'mock-require'; import { config, firebaseConfig } from '../src/config'; @@ -32,6 +33,7 @@ describe('config()', () => { delete process.env.PWD; }); afterEach(() => { + mockFs.restore(); mockRequire.stopAll(); delete config.singleton; delete process.env.FIREBASE_CONFIG; @@ -39,12 +41,55 @@ describe('config()', () => { }); it('loads config values from .runtimeconfig.json', () => { - mockRequire('/srv/.runtimeconfig.json', { foo: 'bar', firebase: {} }); + const configPath = '/srv/.runtimeconfig.json'; + const mockConfig = { + foo: 'bar', + firebase: {}, + }; + mockRequire(configPath, mockConfig); + mockFs({ [configPath]: JSON.stringify(mockConfig) }); + const loaded = config(); expect(loaded).to.not.have.property('firebase'); expect(loaded).to.have.property('foo', 'bar'); }); + it('loads config values from hoisted .runtimeconfig.json', () => { + const hoistedConfigPath = '/.runtimeconfig.json'; // One directory up from /srv + const mockConfig = { + foo: 'bar', + firebase: {}, + }; + mockRequire(hoistedConfigPath, mockConfig); + mockFs({ [hoistedConfigPath]: JSON.stringify(mockConfig) }); + + const loaded = config(); + expect(loaded).to.not.have.property('firebase'); + expect(loaded).to.have.property('foo', 'bar'); + }); + + it('loads config values from node_modules sibling before other hoisted .runtimeconfig.json files', () => { + const hoistedConfigPath = '/.runtimeconfig.json'; // One directory up from /srv + const nodeModulesSiblingConfigPath = '/srv/.runtimeconfig.json'; + const configValid = { + foo: 'bar', + firebase: {}, + }; + const configInvalid = { + foo: 'baz', + firebase: {}, + }; + mockRequire(hoistedConfigPath, configInvalid); + mockRequire(nodeModulesSiblingConfigPath, configValid); + mockFs({ + [hoistedConfigPath]: JSON.stringify(configInvalid), + [nodeModulesSiblingConfigPath]: JSON.stringify(configValid), + }); + + const loaded = config(); + expect(loaded).to.have.property('foo', configValid.foo); + }); + it('does not provide firebase config if .runtimeconfig.json not invalid', () => { mockRequire('/srv/.runtimeconfig.json', 'does-not-exist'); expect(firebaseConfig()).to.be.null; diff --git a/src/config.ts b/src/config.ts index 8925ff940..e9231709e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ import * as firebase from 'firebase-admin'; import * as path from 'path'; +import { existsSync, findUpSync } from './utilities/fs'; export function config(): config.Config { if (typeof config.singleton === 'undefined') { @@ -50,6 +51,25 @@ export namespace config { export let singleton: config.Config; } +/** @hidden */ +function findConfigPath(): string { + // Config path preferences in order: + // 1. ENV value CLOUD_RUNTIME_CONFIG. + // 2. The .runtimeconfig.json in the working directory. + // 3. The .runtimeconfig.json in ancestor directory (traversing up from PWD). + + if (process.env.CLOUD_RUNTIME_CONFIG) { + return process.env.CLOUD_RUNTIME_CONFIG as string; + } + + const configPathAtPwd = path.join(process.env.PWD, '.runtimeconfig.json'); + if (existsSync(configPathAtPwd)) { + return configPathAtPwd; + } + + return findUpSync('.runtimeconfig.json') as string; +} + /** @hidden */ export function firebaseConfig(): firebase.AppOptions | null { const env = process.env.FIREBASE_CONFIG; @@ -69,10 +89,7 @@ export function firebaseConfig(): firebase.AppOptions | null { // Could have Runtime Config with Firebase in it as an ENV location or default. try { - const configPath = - process.env.CLOUD_RUNTIME_CONFIG || - path.join(process.env.PWD, '.runtimeconfig.json'); - const config = require(configPath); + const config = require(findConfigPath()); if (config.firebase) { return config.firebase; } @@ -94,10 +111,7 @@ function init() { } try { - const configPath = - process.env.CLOUD_RUNTIME_CONFIG || - path.join(process.env.PWD, '.runtimeconfig.json'); - const parsed = require(configPath); + const parsed = require(findConfigPath()); delete parsed.firebase; config.singleton = parsed; return; diff --git a/src/utilities/fs.ts b/src/utilities/fs.ts new file mode 100644 index 000000000..ba8fc0251 --- /dev/null +++ b/src/utilities/fs.ts @@ -0,0 +1,97 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Like fs.existsSync, but fails softly. + * @param path A path to check exists. + */ +export function existsSync(path): boolean | void { + try { + return fs.existsSync(path); + } catch (err) { + return undefined; + } +} + +type PathType = 'file' | 'directory'; + +interface LocatePathOptions { + cwd?: string; + allowSymlinks?: boolean; + type?: PathType; +} + +const stop = Symbol('findUp.stop'); + +const pathTypeMap: { [K in PathType]: string } = { + directory: 'isDirectory', + file: 'isFile', +}; + +function locatePathSync(paths, options: LocatePathOptions) { + options = { + cwd: process.cwd(), + allowSymlinks: true, + type: 'file', + ...options, + }; + + if (!(options.type in pathTypeMap)) { + throw new Error(`Invalid type specified: ${options.type}`); + } + + const statFn = options.allowSymlinks ? fs.statSync : fs.lstatSync; + + for (const currentPath of paths) { + try { + const stat = statFn(path.resolve(options.cwd, currentPath)); + + if (options.type === undefined || stat[pathTypeMap[options.type]]()) { + return currentPath; + } + } catch {} + } +} + +/** + * Finds the closest file matching the given file name, traversing up. + * @param fileName The filename to look up, starting from cwd. + * @param options The options change findUp behaviour. + */ +export function findUpSync( + fileName, + options: LocatePathOptions = {} +): string | void { + let directory = path.resolve(options.cwd || ''); + const { root } = path.parse(directory); + const paths = [].concat(fileName); + + const runMatcher = (locateOptions) => { + if (typeof fileName !== 'function') { + return locatePathSync(paths, locateOptions); + } + + const foundPath = fileName(locateOptions.cwd); + if (typeof foundPath === 'string') { + return locatePathSync([foundPath], locateOptions); + } + + return foundPath; + }; + + while (true) { + const foundPath = runMatcher({ ...options, cwd: directory }); + + if (foundPath === stop) { + return; + } + if (foundPath) { + return path.resolve(directory, foundPath); + } + if (directory === root) { + return; + } + + directory = path.dirname(directory); + } +}