import Config from "../config.mjs"; import path from "path"; import fs from "fs"; import chalk from "chalk"; import { compilePack, extractPack, TYPE_COLLECTION_MAP } from "../lib/package.mjs"; /** * @typedef {"Module"|"System"|"World"} PackageType */ /** * @typedef {object} CLIArgs * @property {"workon"|"clear"|"unpack"|"pack"} action The action to perform. * @property {string} value The action value. * @property {string} [id] Optionally provide the package ID if we are using explicit * paths. * @property {PackageType} type The package type. * @property {string} [compendiumName] The compendium name for pack-based actions. * @property {DocumentType} [compendiumType] The type of Documents that the compendium houses. Only required * for NeDB operations. * @property {string} [inputDirectory] An explicit input directory for pack-based actions. * @property {string} [outputDirectory] An explicit output directory for pack-based actions. * @property {boolean} [yaml] Whether to use YAML instead of JSON for serialization. * @property {boolean} [verbose] Whether to output verbose logging. * @property {boolean} [nedb] Use NeDB instead of ClassicLevel for database operations. * @property {boolean} [recursive] When packing, recurse down through all directories in the input * directory to find source files. * @property {boolean} [clean] When unpacking, delete the destination directory first. */ /** * The working package ID. * @type {string|null} */ let currentPackageId = Config.instance.get("currentPackageId"); /** * The working package type. * @type {PackageType|null} */ let currentPackageType = Config.instance.get("currentPackageType"); /** * Get the command object for the package command * @returns {CommandModule} */ export function getCommand() { return { command: "package [action] [value]", describe: "Manage packages", builder: yargs => { yargs.positional("action", { describe: "The action to perform", type: "string", choices: ["workon", "clear", "unpack", "pack"] }); yargs.positional("value", { describe: "The value to use for the action", type: "string" }); // currentPackageId is only needed if the data path has to be built with it. yargs.option("id", { describe: "The package ID", type: "string", }); yargs.option("type", { describe: "The package type", type: "string", choices: ["Module", "System", "World"] }); yargs.option("compendiumName", { alias: "n", describe: "The Compendium name, for Compendium Pack based Actions.", type: "string" }); yargs.option("compendiumType", { alias: "t", describe: "The type of document that the compendium pack stores. Only necessary for NeDB operations.", type: "string", choices: Object.keys(TYPE_COLLECTION_MAP) }); yargs.option("inputDirectory", { alias: "in", describe: "The directory to read from, for Pack based Actions.", type: "string" }); yargs.option("outputDirectory", { alias: "out", describe: "The directory to write to, for Pack based Actions.", type: "string" }); yargs.option("yaml", { describe: "Whether to use YAML instead of JSON for serialization.", type: "boolean" }); yargs.option("verbose", { alias: "v", describe: "Whether to output verbose logging.", type: "boolean" }); yargs.option("nedb", { describe: "Whether to use NeDB instead of ClassicLevel for database operations.", type: "boolean" }); yargs.option("recursive", { alias: "r", describe: "When packing, recurse down through all directories in the input directory to find source files.", type: "boolean" }); yargs.option("clean", { alias: "c", describe: "When unpacking, delete the destination directory first.", type: "boolean" }); return yargs; }, handler: async argv => { if ( argv.id ) currentPackageId = argv.id; if ( argv.type ) currentPackageType = argv.type; // Handle actions switch ( argv.action ) { case "workon": handleWorkon(argv); break; case "clear": handleClear(); break; case "unpack": await handleUnpack(argv); break; case "pack": await handlePack(argv); break; default: if ( !currentPackageId ) { console.error(chalk.red("No package ID is currently set. Use `package workon ` to set it.")); return; } console.log(`Currently in ${chalk.magenta(currentPackageType)} ${chalk.cyan(currentPackageId)}`); break; } } } } /* -------------------------------------------- */ /* Helpers */ /* -------------------------------------------- */ /** * Read all the package manifests for a given package type, and return a Map of them, keyed by ID. * @param {string} dataPath The root data path. * @param {PackageType} type The package type. * @param {object} [options] * @param {boolean} [options.verbose=false] Log errors verbosely. * @returns {Map} */ function readPackageManifests(dataPath, type, { verbose=false }={}) { const typeLC = type.toLowerCase(); const typePl = `${typeLC}s`; const dir = `${dataPath}/Data/${typePl}`; const map = new Map(); for ( const file of fs.readdirSync(dir, { withFileTypes: true }) ) { const manifestPath = path.normalize(`${dir}/${file.name}/${typeLC}.json`); try { const data = JSON.parse(fs.readFileSync(manifestPath, "utf8")); data.type = type; data.path = manifestPath; map.set(data.id ?? data.name, data); } catch ( err ) { if ( verbose ) console.error(chalk.red(`Error reading ${typeLC}.json for ${chalk.blue(file.name)}: ${err}`)); } } return map; } /* -------------------------------------------- */ /** * @typedef {object} DiscoveredPackages * @property {Map} modules A map of module manifest data by ID. * @property {Map} systems A map of system manifest data by ID. * @property {Map} worlds A map of world manifest data by ID. * @property {object[]} packages A list of all packages in the data path. */ /** * Discover the list of all Packages in the dataPath * @param {CLIArgs} argv The command line arguments * @returns {DiscoveredPackages|void} */ function discoverPackageDirectory(argv) { const dataPath = Config.instance.get("dataPath"); if ( !dataPath ) { console.error(chalk.red(`No dataPath configured. Call ${chalk.yellow("`configure set dataPath `")} first.`)); return; } const modules = readPackageManifests(dataPath, "Module", { verbose: argv.verbose }); const systems = readPackageManifests(dataPath, "System", { verbose: argv.verbose }); const worlds = readPackageManifests(dataPath, "World", { verbose: argv.verbose }); return { modules, systems, worlds, packages: [...modules.values(), ...systems.values(), ...worlds.values()] }; } /* -------------------------------------------- */ /** * Determine the document type of an NeDB database from the command-line arguments, if specified, or from the database's * containing package. * @param {string} packFile The path to the NeDB database. * @param {CLIArgs} argv The command-line arguments. * @returns {string|void} The document type of the NeDB database if it could be determined. */ function determineDocumentType(packFile, argv) { // Case 1 - The document type has been explicitly provided. if ( argv.compendiumType ) return argv.compendiumType; // Case 2 - The type can be inferred from the pack name. const packName = path.basename(packFile, ".db"); for ( const [type, collection] of Object.entries(TYPE_COLLECTION_MAP) ) { if ( packName === collection ) return type; } // Case 3 - Attempt to locate this pack's metadata in the manifest of the package that owns it. const game = discoverPackageDirectory(argv); const pkg = game.packages.find(p => packFile.startsWith(path.dirname(p.path))); const pack = pkg?.packs.find(pack => path.resolve(path.dirname(pkg.path), pack.path) === packFile); if ( !pack ) { console.error(`Unable to determine document type for NeDB compendium at '${packFile}'. ` + "Set this manually with -t ."); return; } return pack.type ?? pack.entity; } /* -------------------------------------------- */ /** * Determines whether a file is locked by another process * @param {string} filepath The file path to test. * @returns {boolean} */ function isFileLocked(filepath) { try { // Try to open the file with the 'w' flag, which requests write access const fd = fs.openSync(filepath, 'w'); // If the file was successfully opened, it is not locked fs.closeSync(fd); return false; } catch ( err ) { if ( err.code === "EBUSY" ) return true; // If the file could not be opened, it is locked else if ( err.code === "ENOENT" ) return false; // If the file can't be found it's not locked throw err; } } /* -------------------------------------------- */ /** * @typedef {object} OperationPaths * @property {string} source The source data files. * @property {string} pack The path to the compendium pack. */ /** * Determine compendium pack and source data paths based on the current configuration or command-line arguments. * @param {CLIArgs} argv The command-line arguments. * @param {"pack"|"unpack"} operation The operation. * @returns {OperationPaths|{}} The paths required for the operation, or nothing if they could not be determined. */ function determinePaths(argv, operation) { const usingDefault = !argv.outputDirectory || !argv.inputDirectory; if ( usingDefault && (!currentPackageId || !currentPackageType) ) { console.error("Package ID or type could not be determined. Use `package workon ` to set it."); return {}; } const dataPath = Config.instance.get("dataPath"); if ( usingDefault && !dataPath ) { console.error("No dataPath configured. Use `configure set dataPath ` to set it."); return {}; } const typeDir = `${currentPackageType.toLowerCase()}s`; const compendiumName = argv.compendiumName ?? argv.value; if ( !compendiumName ) { console.error("No compendium name provided. Use `-n ` to supply it."); return {}; } let pack = operation === "pack" ? argv.outputDirectory : argv.inputDirectory; let source = operation === "pack" ? argv.inputDirectory : argv.outputDirectory; if ( pack ) pack = path.join(pack, compendiumName); else pack = path.join(dataPath, "Data", typeDir, currentPackageId, "packs", compendiumName); source ??= path.join(pack, "_source"); if ( argv.nedb ) pack += ".db"; return { source: path.resolve(path.normalize(source)), pack: path.resolve(path.normalize(pack)) }; } /* -------------------------------------------- */ /* Workon */ /* -------------------------------------------- */ /** * Set the current package ID and type * @param {CLIArgs} argv The command line arguments */ function handleWorkon(argv) { if ( argv.value ) currentPackageId = argv.value; Config.instance.set("currentPackageId", currentPackageId); // Attempt to automatically determine the package type. if ( !argv.type ) { const game = discoverPackageDirectory(argv); const pkgCount = game.packages.filter(p => p.id === currentPackageId).length; if ( pkgCount > 1 ) { console.error(chalk.red(`Multiple packages with ID ${chalk.cyan(currentPackageId)} found. ` + `Please specify the package type with ${chalk.yellow("--type")}`)); process.exitCode = 1; return; } const pkg = game.worlds.get(currentPackageId) ?? game.systems.get(currentPackageId) ?? game.modules.get(currentPackageId); if ( !pkg ) { console.error(chalk.red(`No package with ID ${chalk.cyan(currentPackageId)} found.`)); process.exitCode = 1; return; } currentPackageType = pkg.type; } Config.instance.set("currentPackageType", currentPackageType); console.log(`Swapped to ${chalk.magenta(currentPackageType)} ${chalk.cyan(currentPackageId)}`); } /* -------------------------------------------- */ /** * Clear the current package ID and type */ function handleClear() { currentPackageId = null; currentPackageType = null; Config.instance.set("currentPackageId", currentPackageId); Config.instance.set("currentPackageType", currentPackageType); console.log("Cleared current Package"); } /* -------------------------------------------- */ /* Unpacking */ /* -------------------------------------------- */ /** * Load a compendium pack and serialize the DB entries, each to their own file * @param {CLIArgs} argv The command line arguments * @returns {Promise} */ async function handleUnpack(argv) { const { source, pack } = determinePaths(argv, "unpack"); if ( !source || !pack ) { process.exitCode = 1; return; } let documentType; const { nedb, yaml, clean } = argv; if ( nedb ) { documentType = determineDocumentType(pack, argv); if ( !documentType ) { process.exitCode = 1; return; } } if ( !nedb && isFileLocked(path.join(pack, "LOCK")) ) { console.error(chalk.red(`The pack "${chalk.blue(pack)}" is currently in use by Foundry VTT. ` + "Please close Foundry VTT and try again.")); process.exitCode = 1; return; } const dbMode = nedb ? "nedb" : "classic-level"; console.log(`[${dbMode}] Unpacking "${chalk.blue(pack)}" to "${chalk.blue(source)}"`); try { await extractPack(pack, source, { nedb, yaml, documentType, clean, log: true }); } catch ( err ) { console.error(err); process.exitCode = 1; } } /* -------------------------------------------- */ /* Packing */ /* -------------------------------------------- */ /** * Read serialized files from a directory and write them to a compendium pack. * @param {CLIArgs} argv The command line arguments * @returns {Promise} * @private */ async function handlePack(argv) { const { source, pack } = determinePaths(argv, "pack"); if ( !source || !pack ) { process.exitCode = 1; return; } const { nedb, yaml, recursive } = argv; if ( !nedb && isFileLocked(path.join(pack, "LOCK")) ) { console.error(chalk.red(`The pack "${chalk.blue(pack)}" is currently in use by Foundry VTT. ` + "Please close Foundry VTT and try again.")); process.exitCode = 1; return; } const dbMode = nedb ? "nedb" : "classic-level"; console.log(`[${dbMode}] Packing "${chalk.blue(source)}" into "${chalk.blue(pack)}"`); try { await compilePack(source, pack, { nedb, yaml, recursive, log: true }); } catch ( err ) { console.error(err); process.exitCode = 1; } }