#!/usr/bin/env node
/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

'use strict';

const ora = require('ora');
const path = require('path');
const yargs = require('yargs');
const {hashElement} = require('folder-hash');
const promptForOTP = require('./prompt-for-otp');
const {PUBLISHABLE_PACKAGES} = require('./shared/packages');
const {
  execHelper,
  getDateStringForCommit,
  spawnHelper,
} = require('./shared/utils');
const {buildPackages} = require('./shared/build-packages');
const {readJson, writeJson} = require('fs-extra');

/**
 * Script for publishing PUBLISHABLE_PACKAGES to npm. By default, this runs in tarball mode, meaning
 * the script will only print out what the contents of the files included in the npm tarball would
 * be.
 *
 * Please run this first (ie `yarn npm:publish`) and double check the contents of the files that
 * will be pushed to npm.
 *
 * If it looks good, you can run `yarn npm:publish --for-real` to really publish to npm. You must
 * have 2FA enabled first and the script will prompt you to enter a 2FA code before proceeding.
 * There's a small annoying delay before the packages are actually pushed to give you time to panic
 * cancel. In this mode, we will bump the version field of each package's package.json, and git
 * commit it. Then, the packages will be published to npm.
 *
 * Optionally, you can add the `--debug` flag to `yarn npm:publish --debug --for-real` to run all
 * steps, but the final npm publish step will have the `--dry-run` flag added to it. This will make
 * the command only report what it would have done, instead of actually publishing to npm.
 */
async function main() {
  const argv = yargs(process.argv.slice(2))
    .option('packages', {
      description: 'which packages to publish, defaults to all',
      choices: PUBLISHABLE_PACKAGES,
      default: PUBLISHABLE_PACKAGES,
    })
    .option('for-real', {
      alias: 'frfr',
      description:
        'whether to publish to npm (npm publish) or dryrun (npm publish --dry-run)',
      type: 'boolean',
      default: false,
    })
    .option('debug', {
      description:
        'If enabled, will always run npm commands in dry run mode irregardless of the for-real flag',
      type: 'boolean',
      default: false,
    })
    .option('ci', {
      description: 'Publish packages via CI',
      type: 'boolean',
      default: false,
    })
    .option('tag', {
      description: 'Tag to publish to npm',
      type: 'choices',
      choices: ['experimental', 'beta', 'rc'],
      default: 'experimental',
    })
    .option('tag-version', {
      description:
        'Optional tag version to append to tag name, eg `1` becomes 0.0.0-rc.1',
      type: 'number',
      default: null,
    })
    .option('version-name', {
      description: 'Version name',
      type: 'string',
      default: '0.0.0',
    })
    .help('help')
    .strict()
    .parseSync();

  if (argv.debug === false) {
    const currBranchName = await execHelper('git rev-parse --abbrev-ref HEAD');
    const isPristine = (await execHelper('git status --porcelain')) === '';
    if (currBranchName !== 'main' || isPristine === false) {
      throw new Error(
        'This script must be run from the `main` branch with no uncommitted changes'
      );
    }
  }

  let pkgNames = argv.packages;
  if (Array.isArray(argv.packages) === false) {
    pkgNames = [argv.packages];
  }
  const spinner = ora(
    `Preparing to publish ${argv.versionName}@${argv.tag} ${
      argv.forReal === true ? '(for real)' : '(dry run)'
    } [debug=${argv.debug}]`
  ).info();

  await buildPackages(pkgNames);

  if (argv.forReal === false) {
    spinner.info('Dry run: Report tarball contents');
    for (const pkgName of pkgNames) {
      console.log(`\n========== ${pkgName} ==========\n`);
      spinner.start(`Running npm pack --dry-run\n`);
      try {
        await spawnHelper('npm', ['pack', '--dry-run'], {
          cwd: path.resolve(__dirname, `../../packages/${pkgName}`),
          stdio: 'inherit',
        });
      } catch (e) {
        spinner.fail(e.toString());
        throw e;
      }
      spinner.stop(`Successfully packed ${pkgName} (dry run)`);
    }
    spinner.succeed(
      'Please confirm contents of packages before publishing. You can run this command again with --for-real to publish to npm'
    );
  }

  if (argv.forReal === true) {
    const commit = await execHelper(
      'git show -s --no-show-signature --format=%h',
      {
        cwd: path.resolve(__dirname, '..'),
      }
    );
    const dateString = await getDateStringForCommit(commit);
    const otp =
      argv.ci === false && argv.debug === false ? await promptForOTP() : null;
    const {hash} = await hashElement(path.resolve(__dirname, '../..'), {
      encoding: 'hex',
      folders: {exclude: ['node_modules']},
      files: {exclude: ['.DS_Store']},
    });
    const truncatedHash = hash.slice(0, 7);
    let newVersion =
      argv.tagVersion == null || argv.tagVersion === ''
        ? `${argv.versionName}-${argv.tag}`
        : `${argv.versionName}-${argv.tag}.${argv.tagVersion}`;
    if (argv.tag === 'experimental' || argv.tag === 'beta') {
      newVersion = `${newVersion}-${truncatedHash}-${dateString}`;
    }

    for (const pkgName of pkgNames) {
      const pkgDir = path.resolve(__dirname, `../../packages/${pkgName}`);
      const pkgJsonPath = path.resolve(
        __dirname,
        `../../packages/${pkgName}/package.json`
      );

      spinner.start(`Writing package.json for ${pkgName}@${newVersion}`);
      await writeJson(
        pkgJsonPath,
        {
          ...(await readJson(pkgJsonPath)),
          version: newVersion,
        },
        {spaces: 2}
      );
      spinner.succeed(`Wrote package.json for ${pkgName}@${newVersion}`);

      console.log(`\n========== ${pkgName} ==========\n`);
      spinner.start(`Publishing ${pkgName}@${newVersion} to npm\n`);

      let opts = [];
      if (argv.debug === true) {
        opts.push('--dry-run');
      }
      if (otp != null) {
        opts.push(`--otp=${otp}`);
      }
      /**
       * Typically, the `latest` tag is reserved for stable package versions. Since the the compiler
       * is still pre-release, until we have a stable release let's only add the
       * `latest` tag to non-experimental releases.
       *
       * `latest` is added by default, so we only override it for experimental releases so that
       * those don't get the `latest` tag.
       *
       * TODO: Update this when we have a stable release.
       */
      if (argv.tag === 'experimental') {
        opts.push('--tag=experimental');
      } else {
        opts.push('--tag=latest');
      }
      try {
        await spawnHelper(
          'npm',
          ['publish', ...opts, '--registry=https://registry.npmjs.org'],
          {
            cwd: pkgDir,
            stdio: 'inherit',
          }
        );
        console.log('\n');
      } catch (e) {
        spinner.fail(e.toString());
        throw e;
      }
      spinner.succeed(`Successfully published ${pkgName} to npm`);

      spinner.start('Pushing tags to npm');
      if (typeof argv.tag === 'string') {
        try {
          let opts = ['dist-tag', 'add', `${pkgName}@${newVersion}`, argv.tag];
          if (otp != null) {
            opts.push(`--otp=${otp}`);
          }
          if (argv.debug === true) {
            spinner.info(`dry-run: npm ${opts.join(' ')}`);
          } else {
            await spawnHelper('npm', opts, {
              cwd: pkgDir,
              stdio: 'inherit',
            });
          }
        } catch (e) {
          spinner.fail(e.toString());
          throw e;
        }
        spinner.succeed(
          `Successfully pushed dist-tag ${argv.tag} for ${pkgName} to npm`
        );
      }
    }

    console.log('\n\n✅ All done');
  }
}

main();
