index.js

import fs from "fs";
import path from "path";
import archiver from "archiver";

/**
 * @typedef {object} PackageOptions
 * @property {string} [name="project-package"] - The base name for the output package file.
 * @property {string} inputDir - The path to the directory containing files to be packaged. This is a mandatory option.
 * @property {string} [outputDir="./dist/packages/"] - The path where the package file will be created.
 * @property {boolean} [timestamp=true] - Whether to append a timestamp (YYYYMMDD) to the package filename.
 * @property {boolean} [increment=true] - Whether to append an incrementing number if a file with the same base name and timestamp already exists. This only applies if `overwrite` is `false`.
 * @property {boolean} [overwrite=false] - Whether to overwrite an existing file if `increment` is `false` and a filename clash occurs. If `true`, the existing file will be replaced.
 * @property {string} [format="zip"] - The archive format (e.g., "zip", "tar"). Must be supported by the 'archiver' library.
 * @property {object} [archiverOptions={}] - Additional options passed directly to the 'archiver' library's instance.
 */
const defaults = {
  name: "project-package",
  inputDir: null,
  outputDir: "./dist/packages/",
  timestamp: true,
  increment: true,
  overwrite: false,
  format: "zip",
  archiverOptions: {}
};

/**
 * Creates a project package (e.g., a zip file) based on the provided options.
 * This is the main asynchronous function to initiate the packaging process.
 *
 * @param {object} config - Options for creating the package.
 * @param {string} config.inputDir - The path to the directory containing files to be packaged. This is a mandatory option and must be a valid, existing path.
 * @param {string} [config.name="project-package"] - The base name for the output package file.
 * @param {string} [config.outputDir="./dist/packages/"] - The path where the package file will be created.
 * @param {boolean} [config.timestamp=true] - Whether to append a timestamp (YYYYMMDD) to the package filename.
 * @param {boolean} [config.increment=true] - Whether to append an incrementing number if a file with the same base name and timestamp already exists. This only applies if `config.overwrite` is `false`.
 * @param {boolean} [config.overwrite=false] - Whether to overwrite an existing file if `config.increment` is `false` and a filename clash occurs. If `true`, the existing file will be replaced.
 * @param {string} [config.format="zip"] - The archive format (e.g., "zip", "tar"). Must be supported by the 'archiver' library.
 * @param {object} [config.archiverOptions={}] - Additional options passed directly to the 'archiver' library's instance.
 * @returns {Promise<void>} A Promise that resolves when the package is successfully created.
 * @throws {Error} If `outputDir` or `inputDir` do not exist, or if overwriting is disabled and a filename conflict occurs.
 */
export async function createPackage(config) {
  const options = Object.assign({}, defaults, config);

  if (!fs.existsSync(options.outputDir)) {
    throw new Error("outputDir does not exist");
  } 
  if (!fs.existsSync(options.inputDir)) {
    throw new Error("inputDir does not exist");
  } 

  return await createZip(options, getFilepath(options));
}

/**
 * Internal helper function to create the ZIP archive using the archiver library.
 * It handles the stream piping and promises for completion or error.
 * @private
 * @param {PackageOptions} options - The resolved options for packaging.
 * @param {string} filepath - The full path where the archive file should be written.
 * @returns {Promise<void>} A Promise that resolves when the archive is successfully written to the file system.
 * @throws {Error} If an error occurs during the archiving process (e.g., file system error, archiver error).
 */
function createZip(options, filepath) {
  return new Promise((resolve, reject) => {
    const output = fs.createWriteStream(filepath);
    const archive = archiver(options.format, options.archiverOptions);

    output.on("close", () => {
      console.log(`Package created (${ archive.pointer() } bytes)`);
      resolve();
    });
    archive.on("error", error => {
      reject(error);
    });
    archive.pipe(output);
    archive.directory(options.inputDir, false);
    archive.finalize();
  });
}

/**
 * Generates a unique filepath for the package, incorporating the base name, timestamp,
 * and an incrementing number (if enabled) to prevent overwrites based on options.
 * This function uses recursion to find an available filename if incrementing is active.
 *
 * @param {PackageOptions} options - The resolved options for packaging.
 * @param {number} [count=0] - Internal counter used during recursion to generate incremented filenames. Users should not set this manually.
 * @returns {string} The full, unique path for the new package file, including filename and extension.
 * @throws {Error} If `options.overwrite` is `false`, `options.increment` is `false`, and a file with the exact generated name already exists.
 */
export function getFilepath(options, count = 0) {
  const fileTime = options.timestamp ? `-${ getTimeStamp() }` : "";
  const fileCount = options.increment && count ? `-${ count }` : "";
  const filename = `${ options.name }${ fileTime }${ fileCount }.${ options.format }`;
  const filepath = path.join(options.outputDir, filename);
  if (fs.existsSync(filepath)) {
    if (!options.increment) {
      if (!options.overwrite) {
        throw new Error("Attempting to overwrite file when overwrite is disabled: " + filepath);
      } else {
        return filepath;
      }
    } else {
      return getFilepath(options, ++count);
    }
  } else {
    return filepath;
  }
}

/**
 * Generates a formatted timestamp string for the current date in YYYYMMDD format.
 *
 * @returns {string} The current date as an 8-digit string (e.g., "20250627").
 */
export function getTimeStamp() {
  const now = new Date();
  const pad = (x) => x < 10 ? `0${ x }` : x;
  const month = pad(now.getMonth() + 1);
  const day = pad(now.getDate());
  return `${ now.getFullYear() }${ month }${ day }`;
}

/**
 * Exports the `createPackage` function as the default export of this module.
 * This provides a convenient way to import and use the main packaging functionality.
 *
 * @see {@link createPackage}
 */
export default createPackage;