UNPKG

package-yaml

Version:

Reversible YAML parsing for package.json

568 lines (567 loc) 23.4 kB
#!/usr/bin/env node "use strict"; /// <reference path="../types/yaml.d.ts" /> var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const yaml_1 = __importDefault(require("yaml")); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const deep_diff_1 = require("deep-diff"); const npmlog_1 = __importDefault(require("npmlog")); require("reflect-metadata"); const osenv_1 = __importDefault(require("osenv")); const npm_autoloader_1 = require("npm-autoloader"); const pkg_dir_1 = __importDefault(require("pkg-dir")); npmlog_1.default.heading = 'package-yaml'; if (!!process.env.DEBUG_PACKAGE_YAML) { npmlog_1.default.level = 'verbose'; } const propertyClassesKey = Symbol("propertyClasses"); function property(target, propertyKey) { const propClasses = Reflect.getOwnMetadata(propertyClassesKey, target.constructor) || {}; const propClass = Reflect.getMetadata('design:type', target, propertyKey); propClasses[propertyKey] = propClass; Reflect.defineMetadata(propertyClassesKey, propClasses, target.constructor); } function getPropClasses(target) { const classTarget = typeof target === "object" ? target.constructor : target; const classProperties = Reflect.getOwnMetadata(propertyClassesKey, classTarget) || {}; return classProperties; } function getProps(target) { return Object.keys(getPropClasses(target)); } function getPropClass(target, prop) { return getPropClasses(target)[prop]; } function isPropClass(target, prop, cls) { const propClass = getPropClass(target, prop); return (propClass === cls); } var ConflictResolution; (function (ConflictResolution) { ConflictResolution["ask"] = "ask"; ConflictResolution["useJson"] = "use-json"; ConflictResolution["useYaml"] = "use-yaml"; ConflictResolution["useLatest"] = "use-latest"; })(ConflictResolution || (ConflictResolution = {})); ; class Config { constructor(loadConfigFiles = true) { this.debug = false; this.writeBackups = true; this.backupPath = ".%s~"; // %s - basename; %S - full path with % interpolations this.timestampFuzz = 5; this.conflicts = ConflictResolution.ask; this.tryMerge = true; // Only functions when backups are being written this.defaultExtension = "yaml"; this._lockedProps = {}; if (!!process.env.DEBUG_PACKAGE_YAML) { this.updateAndLock({ debug: true }); } if (process.env.PACKAGE_YAML_FORCE) { const confl = `use-${process.env.PACKAGE_YAML_FORCE}`; if (Config.isValid("conflicts", confl)) { this.updateAndLock({ conflicts: confl }); } } if (loadConfigFiles) { this.loadSystemConfig(); } } loadSystemConfig() { for (let globalPath of ['/etc', '/usr/local/etc']) { // FIXME: this won't work on Windows this.loadConfigFile(path_1.default.join(globalPath, "package-yaml.json")); this.loadConfigFile(path_1.default.join(globalPath, "package-yaml.yaml")); } const home = osenv_1.default.home(); this.loadConfigFile(path_1.default.join(home, ".package-yaml.json")); this.loadConfigFile(path_1.default.join(home, ".package-yaml.yaml")); } static isValid(prop, value) { npmlog_1.default.verbose("Config.isValid", "checking %s: %s", prop, value); if (prop === "conflicts") { npmlog_1.default.verbose("Config.isValid", "ovcfr: %o; includes: %s", Object.values(ConflictResolution), Object.values(ConflictResolution).includes(value)); return typeof value === 'string' && (Object.values(ConflictResolution).includes(value)); } else if (prop === "defaultExtension") { return value === 'yaml' || value === 'yml'; } else if (isPropClass(Config, prop, String)) { return typeof value === 'string'; } else if (isPropClass(Config, prop, Boolean)) { return true; // anything can be a Boolean if you just believe } else if (isPropClass(Config, prop, Number)) { return !isNaN(Number(value)); } return false; } validate(values) { const valid = {}; const propNames = getProps(Config); for (const prop of propNames) { const val = values[prop]; if (this._lockedProps[prop] || !(prop in values) || !Config.isValid(prop, val)) { continue; } if (isPropClass(Config, prop, String)) { valid[prop] = String(values[prop]); // We've already validated these } else if (isPropClass(Config, prop, Boolean)) { valid[prop] = !!values[prop]; } else if (isPropClass(Config, prop, Number)) { valid[prop] = Number(values[prop]); } } return valid; } update(values) { const valid = this.validate(values); Object.assign(this, valid); if ('debug' in valid) { npmlog_1.default.level = valid.debug ? 'verbose' : 'info'; } return valid; } lock(props) { for (let prop of props) { if (prop in this) { this._lockedProps[prop] = true; } } } updateAndLock(values) { const updated = this.update(values); this.lock(Object.keys(updated)); return updated; } loadConfigFile(path, rootElement) { let configData; let configParsed; try { if (!fs_1.default.existsSync(path)) { return null; } configData = fs_1.default.readFileSync(path, { encoding: "utf8" }); } catch (e) { npmlog_1.default.error("loadConfig", "Error loading config file %s: %s", path, e); return null; } try { // YAML parsing *should* work for JSON files without issue configParsed = yaml_1.default.parse(configData); } catch (yamlError) { // try using JSON as a backup try { configParsed = JSON.parse(configData); } catch (jsonError) { const error = path.endsWith(".json") ? jsonError : yamlError; npmlog_1.default.error("loadConfig", "Error parsing YAML/JSON config file %s: %s", path, error); return null; } } if (rootElement) { if (!configParsed || typeof configParsed !== "object" || !configParsed[rootElement]) { // Acceptable, just like if the file didn't exist return null; } configParsed = configParsed[rootElement]; } if (!configParsed || typeof configParsed !== "object") { if (rootElement) { npmlog_1.default.error("loadConfig", "Invalid configuration stanza %s in %s (should be an object)", rootElement, path); } else { npmlog_1.default.error("loadConfig", "Invalid configuration file %s (should be a JSON/YAML object)", path); } return null; } return this.update(configParsed); } } __decorate([ property, __metadata("design:type", Boolean) ], Config.prototype, "debug", void 0); __decorate([ property, __metadata("design:type", Boolean) ], Config.prototype, "writeBackups", void 0); __decorate([ property, __metadata("design:type", String) ], Config.prototype, "backupPath", void 0); __decorate([ property, __metadata("design:type", Number) ], Config.prototype, "timestampFuzz", void 0); __decorate([ property, __metadata("design:type", String) ], Config.prototype, "conflicts", void 0); __decorate([ property, __metadata("design:type", Boolean) ], Config.prototype, "tryMerge", void 0); __decorate([ property, __metadata("design:type", String) ], Config.prototype, "defaultExtension", void 0); ; function loadAndParse(path, parser, inhibitErrors = false) { try { const data = fs_1.default.readFileSync(path, { encoding: "utf8" }); return parser(data); } catch (e) { if (inhibitErrors) { return null; } throw e; } } class Project { constructor(projectDir) { this.config = new Config(); this.yamlModified = false; this.jsonModified = false; this.projectDir = projectDir; this.yamlExtension = fs_1.default.existsSync(this.projectPath('package.yaml')) ? 'yaml' : fs_1.default.existsSync(this.projectPath('package.yml')) ? 'yml' : null; this.config.loadConfigFile(this.projectPath("package-yaml.json")); this.config.loadConfigFile(this.projectPath("package-yaml.yaml")); this.config.loadConfigFile(this.jsonPath, "package-yaml"); this.config.loadConfigFile(this.yamlPath, "package-yaml"); this.jsonExists = fs_1.default.existsSync(this.jsonPath); this.yamlExists = fs_1.default.existsSync(this.yamlPath); } get jsonName() { return "package.json"; } get yamlName() { return `package.${this.yamlExtension || this.config.defaultExtension}`; } projectPath(localPath) { return path_1.default.join(this.projectDir, localPath); } get jsonPath() { return this.projectPath(this.jsonName); } get yamlPath() { return this.projectPath(this.yamlName); } get jsonContents() { if (this._jsonContents) return this._jsonContents; if (this.jsonExists) { try { return this._jsonContents = loadAndParse(this.jsonPath, JSON.parse); } catch (e) { npmlog_1.default.error("loadJson", "Cannot load or parse %s: %s", this.jsonPath, e); throw e; } } else { return this._jsonContents = {}; } } set jsonContents(value) { if (deep_diff_1.diff(this._jsonContents, value)) { this.jsonModified = true; } this._jsonContents = value; } get yamlDocument() { if (this._yamlDocument) return this._yamlDocument; if (this.yamlExists) { try { return this._yamlDocument = loadAndParse(this.yamlPath, yaml_1.default.parseDocument); } catch (e) { npmlog_1.default.error("loadYaml", "Cannot load or parse %s: %s", this.yamlPath, e); throw e; } } else { return this._yamlDocument = new yaml_1.default.Document(); } } set yamlDocument(value) { if (this._yamlDocument !== value) { this.yamlModified = true; } this._yamlDocument = value; } get yamlContents() { return this.yamlDocument.toJSON(); } backupPath(filename) { const fullPath = this.projectPath(filename).replace(/\//g, '%'); const backupPath = this.config.backupPath .replace("%s", filename) .replace("%S", fullPath); return path_1.default.resolve(this.projectDir, backupPath); } writeBackups() { let success = true; if (!this.config.writeBackups) return success; try { fs_1.default.writeFileSync(this.backupPath(this.jsonName), JSON.stringify(this.jsonContents, null, 4)); } catch (e) { success = false; npmlog_1.default.warn("writeBackups", "Error writing backup package.json file at %s: %s", this.backupPath(this.jsonName), e); } try { fs_1.default.writeFileSync(this.backupPath(this.yamlName), this.yamlDocument.toString()); } catch (e) { success = false; npmlog_1.default.warn("writeBackups", "Error writing backup %s file at %s: %s", this.yamlName, this.backupPath(this.yamlName), e); } return success; } writePackageFiles() { let success = true; if (this.yamlModified) { try { fs_1.default.writeFileSync(this.yamlPath, this.yamlDocument.toString()); this.yamlModified = false; } catch (e) { success = false; npmlog_1.default.error("writePackageFiles", "Error writing %s: %s", this.yamlPath, e); } } if (this.jsonModified) { try { fs_1.default.writeFileSync(this.jsonPath, JSON.stringify(this.jsonContents, null, 4)); this.jsonModified = false; } catch (e) { success = false; npmlog_1.default.error("writePackageFiles", "Error writing %s: %s", this.jsonPath, e); } } return success; } patchYaml(diff) { if (diff) { this.yamlDocument = patchYamlDocument(this.yamlDocument, diff); this.yamlModified = true; } return this.yamlDocument; } patchJson(diff) { if (diff) { this.jsonContents = patchObject(this.jsonContents, diff); this.jsonModified = true; } return this.jsonContents; } sync(conflictStrategy) { conflictStrategy = conflictStrategy || this.config.conflicts; if (!deep_diff_1.diff(this.jsonContents, this.yamlContents)) { npmlog_1.default.verbose("sync", "Package files already in sync, writing backups"); this.writeBackups(); return true; } npmlog_1.default.verbose("sync", "Package files out of sync. Trying to resolve..."); if (!this.yamlExists) { npmlog_1.default.verbose("sync", `${this.yamlName} does not exist, creating from package.json`); conflictStrategy = ConflictResolution.useJson; } else if (!this.jsonExists) { npmlog_1.default.verbose("sync", `package.json does not exist, using ${this.yamlName}`); conflictStrategy = ConflictResolution.useYaml; } else if (this.config.writeBackups) { npmlog_1.default.verbose("sync", "Attempting to read backups..."); const jsonBackup = loadAndParse(this.backupPath(this.jsonName), JSON.parse, true) || this.jsonContents; const yamlBackup = loadAndParse(this.backupPath(this.yamlName), yaml_1.default.parse, true) || this.yamlContents; if (!deep_diff_1.diff(this.jsonContents, yamlBackup)) { npmlog_1.default.verbose("sync", "package.yaml has changed, applying to package.json"); conflictStrategy = ConflictResolution.useYaml; } else if (!deep_diff_1.diff(this.yamlContents, jsonBackup)) { npmlog_1.default.verbose("sync", "package.json has changed, applying to package.yaml"); conflictStrategy = ConflictResolution.useJson; } else if (!deep_diff_1.diff(jsonBackup, yamlBackup) && this.config.tryMerge) { npmlog_1.default.verbose("sync", "Both json and yaml have changed, attempting merge"); const jsonDiff = deep_diff_1.diff(jsonBackup, this.jsonContents); const yamlDiff = deep_diff_1.diff(yamlBackup, this.yamlContents); const patchedJson = yamlDiff ? patchObject(JSON.parse(JSON.stringify(this.jsonContents)), yamlDiff) : this.jsonContents; const patchedYaml = jsonDiff ? patchObject(this.yamlContents, jsonDiff) : this.yamlContents; if (!deep_diff_1.diff(patchedJson, patchedYaml)) { npmlog_1.default.verbose("sync", "Merge successful, continuing"); this.patchYaml(jsonDiff); conflictStrategy = ConflictResolution.useYaml; } else { npmlog_1.default.verbose("sync", "Merge unsuccessful, reverting to default resolution (%s)", conflictStrategy); } } else { npmlog_1.default.verbose("sync", "Backup(s) out of sync, reverting to default resolution (%s)", conflictStrategy); } } if (conflictStrategy == ConflictResolution.useLatest) { // We know that both yaml and json must exist, otherwise we wouldn't still be // set to useLatest npmlog_1.default.verbose("sync", "Checking timestamps..."); const jsonTime = fs_1.default.statSync(this.jsonPath).mtimeMs / 1000.0; const yamlTime = fs_1.default.statSync(this.yamlPath).mtimeMs / 1000.0; if (Math.abs(yamlTime - jsonTime) <= this.config.timestampFuzz) { npmlog_1.default.verbose("sync", "Timestamp difference %ss <= fuzz factor %ss, reverting to ask", Math.abs(jsonTime - yamlTime), this.config.timestampFuzz); conflictStrategy = ConflictResolution.ask; } else if (yamlTime > jsonTime) { npmlog_1.default.verbose("sync", "%s %ss newer than package.json, overwriting", this.yamlName, yamlTime - jsonTime); conflictStrategy = ConflictResolution.useYaml; } else { npmlog_1.default.verbose("sync", "package.json %ss newer than %s, overwriting", jsonTime - yamlTime, this.yamlName); conflictStrategy = ConflictResolution.useJson; } } if (conflictStrategy == ConflictResolution.ask) { npmlog_1.default.verbose("sync", "Cannot sync, returning ask"); return ConflictResolution.ask; } if (conflictStrategy == ConflictResolution.useJson) { npmlog_1.default.verbose("sync", "Patching %s with changes from package.json", this.yamlName); this.patchYaml(deep_diff_1.diff(this.yamlContents, this.jsonContents)); } else if (conflictStrategy == ConflictResolution.useYaml) { npmlog_1.default.verbose("sync", "Patching package.json with changes from %s", this.yamlName); this.patchJson(deep_diff_1.diff(this.jsonContents, this.yamlContents)); } this.writeBackups(); return this.writePackageFiles(); } } exports.Project = Project; function patchObject(jsonContents, packageDiff) { for (let diffEntry of packageDiff) { deep_diff_1.applyChange(jsonContents, null, diffEntry); } return jsonContents; } function patchYamlDocument(yamlDoc, packageDiff) { for (const diffEntry of packageDiff) { const editPath = (diffEntry.path || []).concat(diffEntry.kind == 'A' ? diffEntry.index : []); const editItem = (diffEntry.kind == 'A') ? diffEntry.item : diffEntry; if (editItem.kind == 'E' || editItem.kind == 'N') { yamlDoc.setIn(editPath, typeof editItem.rhs == 'undefined' ? undefined : yaml_1.default.createNode(editItem.rhs)); } else if (editItem.kind == 'D') { yamlDoc.deleteIn(editPath); } } return yamlDoc; } class PackageYamlCmd extends npm_autoloader_1.NPMExtensionCommand { constructor() { super(...arguments); this.usage = "npm package-yaml use-yaml\n" + "npm package-yaml use-json"; } execute(args) { npmlog_1.default.verbose("PackageYamlCommand", "called with args: %j", args); const project = new Project(this.npm.config.localPrefix); if (args[0] && args[0].startsWith('use-')) { project.config.updateAndLock({ conflicts: args[0] }); } const syncResult = project.sync(); if (syncResult === 'ask') { console.error("Could not sync package.yaml and package.json. Try executing one of:\n" + " npm package-yaml use-yaml\n" + " npm package-yaml use-json"); } } } function syncPackageYaml(projectDir) { npmlog_1.default.verbose("syncPackageYaml", "loading, projectDir: %s", projectDir); try { const syncResult = new Project(projectDir).sync(); if (syncResult !== true) { return false; // let the caller tell the client what to do } process.on('exit', function () { new Project(projectDir).sync(ConflictResolution.useJson); }); return true; } catch (e) { npmlog_1.default.error("syncPackageYaml", "Unexpected error: %s", e); return false; } } function _npm_autoload(npm, command) { npmlog_1.default.verbose("_npm_autoloader", "called via npm-autoloader"); npm.commands['package-yaml'] = new PackageYamlCmd(npm); if (command == "package-yaml") { npmlog_1.default.verbose("_npm_autoloader", "not automatically syncing because of package-yaml command"); return; } if (!syncPackageYaml(npm.config.localPrefix)) { console.error("Could not sync package.yaml and package.json, aborting. Try executing one of:\n" + " npm package-yaml use-yaml\n" + " npm package-yaml use-json\n" + "and then try this command again."); npm_autoloader_1.npmExit(1); } } exports._npm_autoload = _npm_autoload; if (npm_autoloader_1.calledFromNPM(module)) { npmlog_1.default.verbose("(main)", "called via onload-script"); const npm = npm_autoloader_1.getNPM(module); if (!syncPackageYaml(npm.config.localPrefix)) { let cmdline = "[args...]"; if (process.argv.slice(2).every(arg => /^[a-zA-Z0-9_.,\/-]+$/.test(arg))) { cmdline = process.argv.slice(2).join(" "); } console.error("Could not sync package.yaml and package.json. Try executing one of:\n" + ` PACKAGE_YAML_FORCE=yaml npm ${cmdline}\n` + ` PACKAGE_YAML_FORCE=json npm ${cmdline}\n` + "and then try this command again."); npm_autoloader_1.npmExit(1); } } else if (!module.parent) { npmlog_1.default.verbose("(main)", "called directly from command line"); const dir = pkg_dir_1.default.sync(); if (dir) { syncPackageYaml(dir); } else { npmlog_1.default.verbose("(main)", "Cannot find project dir, aborting"); } } else { npmlog_1.default.verbose("(main)", "not main module"); }