packages/ros-cdk-cli/lib/settings.ts (278 lines of code) (raw):

import * as fs from 'fs-extra'; import * as os from 'os'; import * as fs_path from 'path'; import { Tag } from './cdk-toolkit'; import { debug, warning } from './logging'; import { deepMerge, deepGet, deepClone, deepSet } from './util/objects'; export type SettingsMap = { [key: string]: any }; export const PROJECT_CONFIG = 'cdk.json'; export const PROJECT_CONTEXT = 'cdk.context.json'; export const USER_DEFAULTS = '~/.cdk.json'; /** * If a context value is an object with this key set to a truthy value, it won't be saved to cdk.context.json */ export const TRANSIENT_CONTEXT_KEY = '$dontSaveContext'; const CONTEXT_KEY = 'context'; export type Arguments = { readonly [name: string]: unknown }; /** * All sources of settings combined */ export class Configuration { public settings = new Settings(); public context = new Context(); public readonly defaultConfig = new Settings({ versionReporting: true, pathMetadata: true, output: 'cdk.out', }); private readonly commandLineArguments: Settings; private readonly commandLineContext: Settings; private _projectConfig?: Settings; private _projectContext?: Settings; private loaded = false; constructor(commandLineArguments?: Arguments) { this.commandLineArguments = commandLineArguments ? Settings.fromCommandLineArguments(commandLineArguments) : new Settings(); this.commandLineContext = this.commandLineArguments.subSettings([CONTEXT_KEY]).makeReadOnly(); } private get projectConfig() { if (!this._projectConfig) { throw new Error('#load has not been called yet!'); } return this._projectConfig; } private get projectContext() { if (!this._projectContext) { throw new Error('#load has not been called yet!'); } return this._projectContext; } /** * Load all config */ public async load(): Promise<this> { const userConfig = await loadAndLog(USER_DEFAULTS); this._projectConfig = await loadAndLog(PROJECT_CONFIG); this._projectContext = await loadAndLog(PROJECT_CONTEXT); this.context = new Context( this.commandLineContext, this.projectConfig.subSettings([CONTEXT_KEY]).makeReadOnly(), this.projectContext, ); // Build settings from what's left this.settings = this.defaultConfig .merge(userConfig) .merge(this.projectConfig) .merge(this.commandLineArguments) .makeReadOnly(); debug('merged settings:', this.settings.all); this.loaded = true; return this; } /** * Save the project context */ public async saveContext(): Promise<this> { if (!this.loaded) { return this; } // Avoid overwriting files with nothing await this.projectContext.save(PROJECT_CONTEXT); return this; } } async function loadAndLog(fileName: string): Promise<Settings> { const ret = new Settings(); await ret.load(fileName); if (!ret.empty) { debug(fileName + ':', JSON.stringify(ret.all, undefined, 2)); } return ret; } /** * Class that supports overlaying property bags * * Reads come from the first property bag that can has the given key, * writes go to the first property bag that is not readonly. A write * will remove the value from all property bags after the first * writable one. */ export class Context { private readonly bags: Settings[]; constructor(...bags: Settings[]) { this.bags = bags.length > 0 ? bags : [new Settings()]; } public get keys(): string[] { return Object.keys(this.all); } public has(key: string) { return this.keys.indexOf(key) > -1; } public get all(): { [key: string]: any } { let ret = new Settings(); // In reverse order so keys to the left overwrite keys to the right of them for (const bag of [...this.bags].reverse()) { ret = ret.merge(bag); } return ret.all; } public get(key: string): any { for (const bag of this.bags) { const v = bag.get([key]); if (v !== undefined) { return v; } } return undefined; } public set(key: string, value: any) { for (const bag of this.bags) { if (bag.readOnly) { continue; } // All bags past the first one have the value erased bag.set([key], value); value = undefined; } } public unset(key: string) { this.set(key, undefined); } public clear() { for (const key of this.keys) { this.unset(key); } } } /** * A single bag of settings */ export class Settings { /** * Parse Settings out of CLI arguments. * @param argv the received CLI arguments. * @returns a new Settings object. */ public static fromCommandLineArguments(argv: Arguments): Settings { const context = this.parseStringContextListToObject(argv); const tags = this.parseStringTagsListToObject(expectStringList(argv.tags)); return new Settings({ app: argv.app, browser: argv.browser, context, tags, language: argv.language, pathMetadata: argv.pathMetadata, assetMetadata: argv.assetMetadata, profile: argv.profile, plugin: argv.plugin, requireApproval: argv.requireApproval, toolkitStackName: argv.toolkitStackName, toolkitBucket: { bucketName: argv.bootstrapBucketName, kmsKeyId: argv.bootstrapKmsKeyId, }, versionReporting: argv.versionReporting, staging: argv.staging, output: argv.output, }); } public static mergeAll(...settings: Settings[]): Settings { let ret = new Settings(); for (const setting of settings) { ret = ret.merge(setting); } return ret; } private static parseStringContextListToObject(argv: Arguments): any { const context: any = {}; for (const assignment of (argv as any).context || []) { const parts = assignment.split(/=(.*)/, 2); if (parts.length === 2) { debug('CLI argument context: %s=%s', parts[0], parts[1]); if (parts[0].match(/^aliyun:.+/)) { throw new Error( `User-provided context cannot use keys prefixed with 'aliyun:', but ${parts[0]} was provided.`, ); } context[parts[0]] = parts[1]; } else { warning('Context argument is not an assignment (key=value): %s', assignment); } } return context; } private static parseStringTagsListToObject(argTags: string[] | undefined): Tag[] | undefined { if (argTags === undefined) { return undefined; } if (argTags.length === 0) { return undefined; } const nonEmptyTags = argTags.filter(t => t !== ''); if (nonEmptyTags.length === 0) { return []; } const tags: Tag[] = []; for (const assignment of nonEmptyTags) { const parts = assignment.split('=', 2); if (parts.length === 2) { debug('CLI argument tags: %s=%s', parts[0], parts[1]); tags.push({ Key: parts[0], Value: parts[1], }); } else { warning('Tags argument is not an assignment (key=value): %s', assignment); } } return tags.length > 0 ? tags : undefined; } constructor(private settings: SettingsMap = {}, public readonly readOnly = false) {} public async load(fileName: string): Promise<this> { if (this.readOnly) { throw new Error(`Can't load ${fileName}: settings object is readonly`); } this.settings = {}; const expanded = expandHomeDir(fileName); if (await fs.pathExists(expanded)) { this.settings = await fs.readJson(expanded); } return this; } public async save(fileName: string): Promise<this> { const expanded = expandHomeDir(fileName); await fs.writeJson(expanded, stripTransientValues(this.settings), { spaces: 2 }); return this; } public get all(): any { return this.get([]); } public merge(other: Settings): Settings { return new Settings(deepMerge(this.settings, other.settings)); } public subSettings(keyPrefix: string[]) { return new Settings(this.get(keyPrefix) || {}, false); } public makeReadOnly(): Settings { return new Settings(this.settings, true); } public clear() { if (this.readOnly) { throw new Error('Cannot clear(): settings are readonly'); } this.settings = {}; } public get empty(): boolean { return Object.keys(this.settings).length === 0; } public get(path: string[]): any { return deepClone(deepGet(this.settings, path)); } public set(path: string[], value: any): Settings { if (this.readOnly) { throw new Error(`Can't set ${path}: settings object is readonly`); } if (path.length === 0) { // deepSet can't handle this case this.settings = value; } else { deepSet(this.settings, path, value); } return this; } public unset(path: string[]) { this.set(path, undefined); } } function expandHomeDir(x: string) { if (x.startsWith('~')) { return fs_path.join(os.homedir(), x.substr(1)); } return x; } /** * Return all context value that are not transient context values */ function stripTransientValues(obj: { [key: string]: any }) { const ret: any = {}; for (const [key, value] of Object.entries(obj)) { if (!isTransientValue(value)) { ret[key] = value; } } return ret; } /** * Return whether the given value is a transient context value * * Values that are objects with a magic key set to a truthy value are considered transient. */ function isTransientValue(value: any) { return typeof value === 'object' && value !== null && (value as any)[TRANSIENT_CONTEXT_KEY]; } function expectStringList(x: unknown): string[] | undefined { if (x === undefined) { return undefined; } if (!Array.isArray(x)) { throw new Error(`Expected array, got '${x}'`); } const nonStrings = x.filter(e => typeof e !== 'string'); if (nonStrings.length > 0) { throw new Error(`Expected list of strings, found ${nonStrings}`); } return x; }