<repo_name>marckassay/spypkg<file_sep>/lib/utils.ts
import * as child from 'child_process';
import * as fs from 'fs';
import { promisify } from 'util';
import * as path from 'path';
const fse = require('fs-extra');

// TODO: remove this to make it public; currently conflicts with orginial function
const readFileAsync2 = promisify(fs.readFile);
const writeFileAsync = promisify(fs.writeFile);
const renameFileAsync = promisify(fs.rename);

// tslint:disable-next-line:newspaper-order
export async function readFileAsync(filePath, err_message): Promise<string | never> {
  try {
    const readFile = promisify(fs.readFile);
    return await readFile(filePath, 'utf8')
      .then((value: string) => {
        return value;
      })
      .catch(() => {
        return Promise.reject(err_message);
      });
  } catch {
    return Promise.reject(err_message);
  }
}

export async function doesFileExistAsync(filePath, err_message?: string): Promise<boolean> {
  const doesFileExist = promisify(fs.exists);
  return await doesFileExist(filePath)
    .then((value): boolean => {
      if (value === false && err_message) {
        console.error(err_message);
        process.exit(1001);
      } else {
        return value;
      }
    })
    .catch(() => {
      console.error(err_message);
      process.exit(1001);
      return false; // superfluous, but linter wants it.
    });
}

export async function executeScriptBlock(scriptblock: string, err_message: string): Promise<string> {
  const command = scriptblock.replace(/[\{\}]/g, '');
  const execute = promisify(child.exec);
  return await execute(command)
    .then((value) => {
      if (value.stderr) {
        console.error(err_message + scriptblock);
        process.exit(1003);
      } else {
        return value.stdout.replace(/[\"\']/g, '').trimRight();
      }
    })
    .catch(() => {
      console.error(err_message + scriptblock);
      process.exit(1003);
      return '1003'; // superfluous, but linter wants it.
    });
}

export async function removeFile(filePath: string, err_message: string): Promise<boolean> {
  const remove = promisify(fs.unlink);
  // tslint:disable-next-line:no-bitwise
  err_message;
  /* TODO: disabling this for now; something with Promise<void> and thenable from caller
  if (!checkUsersPermissions(filePath, fs.constants.W_OK | fs.constants.R_OK)) {
      console.error(err_message + filePath);
      process.exit(1004);
      return;
    }
  */
  return await remove(filePath)
    .then(() => {
      return true;
    })
    .catch(() => {
      console.error('Although permissions to remove file is correct, failure occurred.' +
        ' Is there another process accessing this file?: ' + filePath);
      process.exit(1004);
      return false;
    });
}

/**
 * Checks the destination for exisitence, if not existent it will create a copy from source.
 */
export async function checkAndCreateACopy(source, destination): Promise<boolean> {
  const copy = promisify(fs.copyFile);
  return await doesFileExistAsync(destination)
    .then(async (value: boolean) => {
      if (!value) {
        await fse.ensureDir(path.dirname(destination));
        await copy(source, destination);
        return true;
      } else {
        return false;
      }
    });
}

export function createSymlink(filePath, linkPath): Promise<void> {
  fs.symlinkSync(path.resolve(filePath), path.resolve(linkPath), 'file');
  return Promise.resolve();
  /*   const slink = promisify(fs.symlink);
    console.log(path.resolve(filePath) + ' --> ' + linkPath);
    return slink(filePath, linkPath)
      .then(() => {
        console.log('linking it');
        return Promise.resolve();
      }, (reason) => {
        console.error('Cant link it');
        process.exit(1007);
        return;
      })
      .catch(() => {
        console.error('Cant link it');
        process.exit(1007);
        return;
      }); */
}

// https://stackoverflow.com/a/46974091/648789
export async function replaceTokenInFile(file, tokenExpression, replacement): Promise<void> {
  const tmpfile = `${file}.tmp`;
  try {
    const contents = await readFileAsync2(file, 'utf8');
    const replaced_contents = contents.replace(tokenExpression, replacement);
    await writeFileAsync(tmpfile, replaced_contents, 'utf8');
    await renameFileAsync(tmpfile, file);
  } catch (error) {
    console.log('ERROR Calling utils.replaceTokenInFile(' + file + ',' + tokenExpression + ',' + replacement + ')');
    console.log(error);
  }
}

export function getFullname(filePath): string {
  return path.basename(filePath);
}
/*
function checkUsersPermissions(filePath, mode): boolean {
  try {
    fs.accessSync(filePath, mode);
    return true;
  } catch (err) {
    return false;
  }
}
*/

/**
 * Changes mode for file to '555'. This function is only for applicable for POSIX.
 *
 * @param filePath The file to change mode to.
 */
export async function makeFileExecutable(filePath): Promise<void> {
  const changeMode = promisify(fs.chmod);
  // octal '555' is expressed as: -r-xr-xr-x
  return changeMode(filePath, '555')
    .then(() => {
      return Promise.resolve();
    }, () => {
      console.error('Unable to make the following file executable for POSIX environments: ' + filePath);
      process.exit(1006);
      return;
    })
    .catch(() => {
      console.error('Unable to make the following file executable for POSIX environments: ' + filePath);
      process.exit(1006);
      return;
    });
}

/*
* Resolve commandDirectoryPath if its in a scriptblock.
*/
export async function checkAndResolveScriptBlock(value): Promise<string> {
  if (value && value.search(/(?<={).*(?=})/) > 0) {
    const scriptBlock = value.match(/(?<={).*(?=})/)[0];
    value = await executeScriptBlock(scriptBlock, 'Unable to execute the following scriptblock: ')
      .then((value) => {
        return value.replace(/[{].*[}]/, value);
      });
  }
  return Promise.resolve(value);
}

export async function readJSONProperty<T>(filepath: string, property?: string): Promise<T> {
  const filename = path.basename(filepath, '.json');
  try {
    await doesFileExistAsync(filepath, 'Unable to load ' + filename);

    const configRaw: string = await readFileAsync(filepath, 'Unable to read ' + filename);

    if (property) {
      return JSON.parse(configRaw)[property];
    } else {
      return JSON.parse(configRaw);
    }
  } catch (error) {
    if (property) {
      throw new Error(`Unable to parse ${property} property ${filename} into a JSON object.`);
    } else {
      throw new Error(`Unable to parse ${filename} into a JSON object.`);
    }
  }
}

export async function asyncForEach<T>(value: Array<T>, callback) {
  for (let index = 0; index < value.length; index++) {
    await callback(value[index], index, value) as T;
  }
}
<file_sep>/scripts/deploy.ts
import * as child from 'child_process';
import * as path from 'path';
import * as fs from 'fs-extra';
import { promisify } from 'util';

const exec = promisify(child.exec);
const changeMode = promisify(fs.chmod);
const copy = promisify(fs.copy);

async function deploy() {
  try {
    // attempts to find 'yarn.lock' for 'yarn', defaults to 'npm'
    const getPackMan = () => {
      try {
        fs.accessSync(path.join(process.cwd(), 'yarn.lock'));
        return 'yarn';
      } catch (error) {
        return 'npm';
      }
    }
    const packManVal = getPackMan();
    const npmExe = packManVal;

    // keep relative paths as-is to avoid possible issues POSIX...
    const shellExe = (process.platform === 'win32') ? 'cmd /c' : '';
    const relativeHarnessSrcPath = 'harness/spypkg-harness';
    const relativeHarnessDestinationPath = '../spypkg-harness';

    try {
      await copy(relativeHarnessSrcPath, relativeHarnessDestinationPath);
      console.log('[spypkg] Copied: ' + relativeHarnessSrcPath + ' -> ' + relativeHarnessDestinationPath);
    } catch (err) {
      console.error('[spypkg] ' + err);
      return;
    }

    if (process.platform !== 'win32') {
      try {
        await makeFileExecutable(relativeHarnessDestinationPath);
        console.log('[spypkg] Relaxed premissions for : ' + relativeHarnessDestinationPath);
      } catch (err) {
        console.error('[spypkg] ' + err);
        return;
      }
    }

    console.log(`[spypkg] step 1/2 - Registering module for '${npmExe}' for linking.`);

    // below is step 1 out of 2 of the node package manager linking process...
    let command = (shellExe + ' ' + npmExe + ' link').trimLeft();
    console.log('[spypkg] Executing: ' + command);
    const successfullyLinked = await exec(command)
      .then((onfulfilled) => {
        if (onfulfilled.stdout) {
          console.log('[spypkg] ' + onfulfilled.stdout);
        }
        if (onfulfilled.stderr) {
          console.error('[spypkg] ' + onfulfilled.stderr);
        }
        return true;
      })
      .catch((reason) => {
        console.error('[spypkg] ' + reason);
        return false;
      });

    // if step 1 out of 2 succeeded, execute step 2 in the harness dir...
    if (successfullyLinked) {
      process.chdir(relativeHarnessDestinationPath);
      console.log(`[spypkg] Changed directory to: ${process.cwd()}`);

      console.log('[spypkg] step 2/2 - Appling new link to harness directory.');

      // ...now execute the link command again specifiying 'spypkg'...
      command = (shellExe + ' ' + npmExe + ' link spypkg --dev').trimLeft();
      console.log('[spypkg] Executing: ' + command);
      await exec(command)
        .then((onfulfilled) => {
          if (onfulfilled.stdout) {
            // console.log(onfulfilled.stdout);
            console.log('[spypkg] Test harness deployed successfully!');
          }
          if (onfulfilled.stderr) {
            console.error('[spypkg] ' + onfulfilled.stderr);
            return;
          }
        })
        .catch((reason) => {
          console.error('[spypkg] ' + reason);
          return;
        });
    }
  } catch (err) {
    console.error('[spypkg] ' + err);
  }
}
deploy();


async function makeFileExecutable(filePath): Promise<void> {
  return changeMode(filePath, '777')
    .then(() => {
      return Promise.resolve();
    }, () => {
      console.error('[spypkg] Unable to make the following file executable for POSIX environments: ' + filePath);
      process.exit(1006);
      return;
    })
    .catch(() => {
      console.error('[spypkg] Unable to make the following file executable for POSIX environments: ' + filePath);
      process.exit(1006);
      return;
    });
}
<file_sep>/lib/index.ts
#!/usr/bin/env node
/**
 * Loads the package.json in the current working directory to parse the spypkg property. Regardless
 * of what form this property is in, it must have at least one spy declared in the spies array and
 * one location with a value to a directory. That directory will be the location the spy is deployed
 * to. If projectOutPath is declared it will redirect execution to that directory, otherwise spypkg
 * will redirect to its dist folder.
 *
 * This is developed to work on POSIX and Windows.
 */
import * as util from './utils';
import { join } from 'path';

// src is optional since removeSpy is only considered about removing the dest file.
interface SpyDeploymentShape {
  src?: string,
  dest: string
}

interface Spy {
  name: string,
  location?: string,
  adaptor?: string
}

interface SpypkgConfig {
  location?: string,
  projectOutPath?: string;
  spies: Array<Spy | string>;
}

// switches..
let verboseSwitch: boolean = false;
let removeSwitch: boolean = false;
let forceSwitch: boolean = false;
let nohashresetSwitch: boolean = false;

const JSONFileName = 'package.json';
const JSONFilePath: string = join(process.cwd(), JSONFileName);
const JSONProperty = 'spypkg';
const builtInAdaptorSymbol = ':*';

const bashDependencyFileName = 'dependency';
const cmdDependencyFileName = 'dependency.cmd';
const adaptorFileName = 'adaptor.js';

const isWin = process.platform === "win32";

/**
 * The location to the `out` folder. This is intended to reside as a sub-directory of project. This
 * is optional, but required if a custom adaptor file is declared in JSON config property.
 */
let projectOutPath: string;

/**
 * The value of where the spies will be deployed to. This is required unless declared for individual
 * spy.
 */
let location: string;

const distBashDependencyFilePath: string = join(__dirname, 'dependency', bashDependencyFileName);
function outBashDependencyFilePath(): string {
  if (!projectOutPath) {
    return distBashDependencyFilePath;
  } else {
    return join(projectOutPath, 'dependency', bashDependencyFileName);
  }
}

const distCmdDependencyFilePath: string = join(__dirname, 'dependency', cmdDependencyFileName);
function outCmdDependencyFilePath(): string {
  if (!projectOutPath) {
    return distCmdDependencyFilePath;
  } else {
    return join(projectOutPath, 'dependency', cmdDependencyFileName);
  }
}

const distAdaptorFilePath: string = join(__dirname, 'adaptor', adaptorFileName);
function outAdaptorFilePath(name: string = adaptorFileName): string {
  return join(projectOutPath, name);
}

/**
 * This will return an extensionless path to the command destination which is valid for the bash file.
 * To have a valid batch file, simply join the value of '.cmd'.
 *
 * @param name the command name, ie, 'cordova', 'npm'.
 * @param commandDirectoryPath the command destination location value
 * @returns an extensionless path to the command destination.
 */
async function resolveScriptLocationPath(name: string, commandDirectoryPath?: string) {
  let resolvedCommandDirectoryPath;
  if (commandDirectoryPath) {
    resolvedCommandDirectoryPath = await util.checkAndResolveScriptBlock(commandDirectoryPath);
  }

  if (!resolvedCommandDirectoryPath && location) {
    resolvedCommandDirectoryPath = location;
  } else if (!resolvedCommandDirectoryPath && !location) {
    throw new Error("[spypkg] Missing 'location' property for 'spypkg' object. See documentation: https://github.com/marckassay/spypkg");
  }

  return join(resolvedCommandDirectoryPath, name.replace(':*', ''));
}

const resolveAdaptorFilePath = (name: string, adaptor: string | undefined) => {
  let results;
  if (name.endsWith(builtInAdaptorSymbol)) {
    name = name.split(builtInAdaptorSymbol)[0];
    results = join(__dirname, 'adaptor', name + '-adaptor.js');
  } else if (adaptor === undefined) {
    results = distAdaptorFilePath;
  } else {
    results = join(process.cwd(), adaptor);
  }
  return results;
}

async function init() {
  // setting command-line switches
  for (let j = 0; j < process.argv.length; j++) {
    if (process.argv[j] === '--verbose') {
      verboseSwitch = true;
    } else if (process.argv[j] === '--remove') {
      removeSwitch = true;
    } else if (process.argv[j] === '--force') {
      forceSwitch = true;
    } else if (process.argv[j] === '--no-hash-reset') {
      nohashresetSwitch = true;
    }
  }

  let config: SpypkgConfig = await util.readJSONProperty<SpypkgConfig>(JSONFilePath, JSONProperty);

  projectOutPath = config.projectOutPath;

  location = config.location;

  if (removeSwitch === false) {
    if (forceSwitch) {
      await removeSpies(config)
    }
    await addSpies(config);
  } else {
    await removeSpies(config);
  }

  if (!isWin && !nohashresetSwitch) {
    await util.executeScriptBlock('{hash -r}', 'Unable to reset hash. Spies may not be called until hash is reset.');
  }
}

async function addSpies(config: SpypkgConfig) {
  await util.asyncForEach<Spy | string>((config.spies), async (spy: Spy | string) => {
    if (typeof spy === 'object') {
      await addSpy(spy.name, spy.location, spy.adaptor);
    } else {
      await addSpy(spy);
    }
  });
}

async function removeSpies(config: SpypkgConfig) {
  await util.asyncForEach<Spy | string>((config.spies), async (spy: Spy | string) => {
    if (typeof spy === 'object') {
      await removeSpy(spy.name, spy.location);
    } else {
      await removeSpy(spy);
    }
  });
}

/**
* Creates a dependency from the data parsed in packages property of package.json.
*
* @param {string} name the command name that is to be used in shell.
* @param {string} commandDirectoryPath the directory of where the files will reside. this is the value
of the `location` property of the JSON object. This value must be listed in your operating system's
environment variable.
* @param {string} adaptor the JS file where the command resolves to. Defaults to `adaptor.js`.
*/
async function addSpy(name: string, commandDirectoryPath?: string, adaptor?: string) {
  const resolvedScriptLocationFilePath: string = await resolveScriptLocationPath(name, commandDirectoryPath);

  // adaptor may be undefined, specifiying to use built-in one or defined.
  let resolvedAdaptorFilePath: string = resolveAdaptorFilePath(name, adaptor);

  // copy files out of spypkg's dist folder and into the client's "out" folder if 'projectOutPath' is defined
  if (projectOutPath) {
    await util.checkAndCreateACopy(distBashDependencyFilePath, outBashDependencyFilePath());
    await util.checkAndCreateACopy(distCmdDependencyFilePath, outCmdDependencyFilePath());
    await util.checkAndCreateACopy(resolvedAdaptorFilePath, outAdaptorFilePath(util.getFullname(resolvedAdaptorFilePath)));
  }

  if (!isWin) {
    await util.makeFileExecutable(outBashDependencyFilePath());
  }

  const arr: SpyDeploymentShape[] = [
    { src: outBashDependencyFilePath(), dest: resolvedScriptLocationFilePath },
    { src: outCmdDependencyFilePath(), dest: resolvedScriptLocationFilePath + '.cmd' }
  ];

  await util.asyncForEach<SpyDeploymentShape>(arr, async (spy: SpyDeploymentShape) => {
    await util.checkAndCreateACopy(spy.src, spy.dest)
      .then((value) => {
        if (verboseSwitch && value) {
          console.log(`[spypkg] Added: ${spy.dest}`);
        }
        return;
      });
    return;
  });

  return Promise.resolve();
}

async function removeSpy(name: string, commandDirectoryPath?: string) {
  const resolvedScriptLocationFilePath: string = await resolveScriptLocationPath(name, commandDirectoryPath);

  await util.asyncForEach<SpyDeploymentShape>([{ dest: resolvedScriptLocationFilePath }, { dest: resolvedScriptLocationFilePath + '.cmd' }], async (spy: SpyDeploymentShape) => {

    let continu: boolean = await util.doesFileExistAsync(spy.dest)
      .then((value: boolean) => {
        if (!value && verboseSwitch) {
          console.log(`[spypkg] File does not exist: ${spy.dest}`);
        }
        return value;
      });

    let fileContent: string = '';
    if (continu) {
      fileContent = await util.readFileAsync(spy.dest, 'Unable to determine if file is from spypkg. Remove or backup the following file: ' + spy.dest);
      continu = (fileContent.length > 0);
    }

    if (continu) {
      const foundSpypkgMsg: boolean = fileContent.search(/(SPYPKG HAS GENERATED THIS FILE!)/) !== -1;
      if (!foundSpypkgMsg) {
        console.log(`[spypkg] This file seems to have not been generated by spypkg. Remove or backup this file: ${spy.dest}`);
        continu = false;
      } else {
        continu = await util.removeFile(spy.dest, 'Unable to remove file. Do you have permissions to access this file?: ');
      }
    }

    if (continu && verboseSwitch) {
      console.log(`[spypkg] Removed: ${spy.dest}`);
    }

    return;
  });

  return Promise.resolve();
}

init();
<file_sep>/scripts/postinstall.ts
import * as path from "path";
import { readJson, writeJson, pathExists } from "fs-extra";

const executingModuleName = 'spypkg';

async function init() {
  const scriptEntries = [
    { 'add-spies': "node ./node_modules/spypkg --verbose" },
    { 'remove-spies': "node ./node_modules/spypkg --remove --verbose" }
  ];

  await insertScriptEntries(scriptEntries);

  const spyProperty = {
    spypkg: {
      spies: [
        "Enter the name of the command. If 'yarn in lieu of npm' spy needed, use: npm:*"
      ],
      location: "Location of where spy binaries will reside. Example: '/home/marc/.spies'"
    }
  };

  await insertSpypkgProperty(spyProperty);
}
init();

// TODO: perhaps can be optimize with Object.defineProperty() or other Object methods. To many
// possibilities to consider for this to be implemented at this time.
/**
 * Inserts `value` into the `scripts` object of the host app's package.json file. If there isn't a
 * `scripts` object, one will be created.
 *
 * @param value single entry or entries to be added to `scripts` object
 */
async function insertScriptEntries(value: object | Array<object>) {

  if (Array.isArray(value) === false) {
    value = [value];
  }

  const JSONFilePath: string = path.resolve('../../package.json');

  await pathExists(JSONFilePath)
    .then(async (val) => {
      if (val) {
        let config: any;
        let names = [];
        try {
          config = await readJson(JSONFilePath);

          if (!config.scripts) {
            const entry = (value as Array<object>).shift();
            names.push(Object.getOwnPropertyNames(entry)[0]);
            Object.assign(config, { scripts: entry });
          }

          (value as Array<object>).forEach((entry) => {
            const name = Object.getOwnPropertyNames(entry)[0];
            const doesObjectExist = typeof (config.scripts[name]) === 'string';
            if (doesObjectExist === false) {
              names.push(name);
              Object.assign(config.scripts, entry);
            }
          });

        } catch (err) {
          console.error(err);
          console.log(`[${executingModuleName}]`, 'Failed to update:', JSONFilePath);
        }

        try {
          const result = await writeJson(JSONFilePath, config, { spaces: 2 });

          names.forEach((entry) => {
            console.log(`[${executingModuleName}]`, `Added '${entry}' command to:`, JSONFilePath);
          });

          return result;
        } catch (err) {
          console.log(`[${executingModuleName}]`, 'Failed to update:', JSONFilePath);
          console.error(err);
        }
      } else {
        return;
      }
    });
}

async function insertSpypkgProperty(value: { spypkg: {} }) {

  const JSONFilePath: string = path.resolve('../../package.json');

  await pathExists(JSONFilePath)
    .then(async (val) => {
      if (val) {
        let config: any;
        const entry = 'spypkg';
        let isSpypkg: boolean;

        try {
          config = await readJson(JSONFilePath);
          isSpypkg = typeof (config.spypkg) === 'object';

          if (isSpypkg === false) {
            Object.assign(config, { spypkg: value.spypkg });
          }

        } catch (err) {
          console.error(err);
          console.log(`[${executingModuleName}]`, 'Failed to update:', JSONFilePath);
        }

        try {
          if (isSpypkg === false) {

            const result = await writeJson(JSONFilePath, config, { spaces: 2 });

            console.log(`[${executingModuleName}]`, `Added '${entry}' property to:`, JSONFilePath);

            return result;
          } else {
            return;
          }
        } catch (err) {
          console.log(`[${executingModuleName}]`, 'Failed to update:', JSONFilePath);
          console.error(err);
        }
      } else {
        return;
      }
    });
}
<file_sep>/scripts/copy.ts
const fs = require('fs-extra')
const path = require("path");

async function copy(srcpath, dstpath) {
  try {
    await fs.copy(srcpath, path.join(process.cwd(), dstpath));
    console.log('[spypkg] Copied: ' + srcpath + ' -> ' + dstpath);
  } catch (err) {
    console.error(err)
  }
}

copy('lib/dependency/dependency', 'dist/lib/dependency/dependency');
copy('lib/dependency/dependency.cmd', 'dist/lib/dependency/dependency.cmd');
<file_sep>/harness/README.md
# spypkg-harness

This folder (harness) of 'spypkg' is to deploy the module's test harness in the same directory as where it resides. This is ideal for local development of 'spypkg'.

To deploy this harness as intended, clone the [repository](https://github.com/marckassay/spypkg.git) and using a CLI execute the following commands in the root directory using a node package manager:

```shell
[npm|yarn|pnpm] run build:spypkg
[npm|yarn|pnpm] run build:harness
[npm|yarn|pnpm] run install:harness
```

This will install the test harness in the same directory as where 'spypkg' resides on the filesystem. This test harness is to quickly see how 'spypkg' will behave as a dependency. So change the CLI directory to the `spypkg-harness` directory and executed the following if you want to use the current configuration:

```shell
[npm|yarn|pnpm] run add-spies
```

The current configuration is in its [`package.json`](https://github.com/marckassay/spypkg/blob/master/harness/spypkg-harness/package.json) file. Below shows relevant information for this this file:

```json
{
  "spypkg": {
    "projectOutPath": "out",
    "spies": [
      "npm:*"
      {
        "name": "ionic",
        "adaptor": "build/dist/ionic-adaptor.js",
        "location": "{yarn global dir}"
      },
      {
        "name": "cordova",
        "location": "C:\\Users\\marc\\AppData\\Roaming\\.spies\\v8",
      },
    ],
    "location": "C:\\Users\\marc\\AppData\\Roaming\\.spies"
  }
}
```
<file_sep>/README.md
# spypkg

spypkg (spy package) is a Node.js package to intercept command-line expressions that have been executed and with these expressions, an adaptor file can modify them to be executed in another form.

As of recent in my development, an issue mentioned [here](https://github.com/apache/cordova-cli/issues/303), [here](https://github.com/apache/cordova-fetch/issues/46) and [here](https://github.com/apache/cordova-cli/pull/292) surfaced when a dependency required `npm` instead of host project's package manager, `yarn`. And because of this issue, spypkg has been developed.

To address this 'npm in lieu of yarn' issue and to publish a solution for similar issues, the following objectives needed to be accomplished:

- Intercept the execution of a shell expression, whether system is POSIX or Windows, and pass this expression to an adaptor file.

- This adaptor file, compatible to both OS systems, would need to parse and map the npm expression to a yarn expression and then execute.

That adaptor file is, '[npm-adaptor.ts](https://github.com/marckassay/spypkg/blob/master/lib/adaptor/npm-adaptor.ts)'.

As a result from development for the original objective, spypkg can also be used to have local packages that are installed in the host project's 'node_modules' directory, be executed as if they were installed in a global directory (The actual dependency may be located anywhere for that matter. See 'Configuration' section below for further information). This may be ideal when concurrently developing projects that rely on different versions of a global-installed dependency.

This is accomplished in the same fashion as 'yarn in lieu of npm' objective. That is, a shell file is needed to be placed in one of the system's environment path directories. With this file in place it will read the current working directory of the shell process and redirect the executed expression to the relative adaptor file. In turn, the adaptor if needed, will modify the expression and by default (using [adaptor.ts](https://github.com/marckassay/spypkg/blob/master/lib/adaptor/adaptor.ts)), will execute the dependency in the relative 'node_modules' directory. If a different action is needed, excluding [npm-adaptor.ts](https://github.com/marckassay/spypkg/blob/master/lib/adaptor/npm-adaptor.ts), a custom adaptor will need to be specified in the configuration.

## Caveats

- When assigning `location` value in the configuration (See "Setup" section below), verify that the location is indeed in the OS's environment `PATH`. And make sure it's in a position with-in `PATH` that no other sub-path intercepts the one intended. An alternative, is to add a new sub-path at the zero index of `PATH` that will be exclusive for spies.

[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/marckassay/spypkg/blob/master/LICENSE) [![npm version](https://img.shields.io/npm/v/spypkg.svg?style=flat)](https://www.npmjs.com/package/spypkg)

## Install

### npm

```shell
npm install spypkg --save-dev
```

### yarn

```shell
yarn add spypkg --dev
```

link: [yarnpkg.com/en/package/spypkg](https://yarnpkg.com/en/package/spypkg)

## Usage

### 'yarn in lieu of npm' configuration example

For this configuration, the `spypkg` property (which is added as a stub on post install) in `package.json`needs a directory path for its location property (e.g. '.spies'). Notice the `:*` is being used to specify to use spypkg's one-off '[npm-adaptor.ts](https://github.com/marckassay/spypkg/blob/master/lib/adaptor/npm-adaptor.ts)' exclusive to npm:

```json
{
  "spypkg": {
    "spies": [
      "npm:*"
    ],
    "location": "/home/marc/.spies"
  }
}
```

Now add script commands as explained in the subsequent note:

- See "Configuration" section below for more about configuring.
- See also "Adding and Removing Spies" section below on how to execute.

### 'local-as-global' configuration example

This example is very similar to the 'yarn in lieu of npm' example. The only difference is that the generic adaptor will redirect the execution to the host project's 'node_module/spypkg/dist' directory.

```json
{
  "spypkg": {
    "spies": [
      "cordova"
    ],
    "location": "/home/marc/.spies"
  }
}
```

 In this scenario, the dependency (cordova) needs to be accessible whether directly or symbolically on the file system. If the project is not intended to be published to the package manager registry, these dependencies can be added/installed by a package manager so that it will be listed in the descriptor file and reside in 'node_modules' directory.
 
 And in an addition to having the dependency in the 'node_modules', initializing a new project with a spy is possible. For instance, cordova and ionic require when creating a new project that the destination directory doesn’t exist. So simply create a directory that will eventually be the actual project directory. Afterwards, for an example, execute cordova to create a project in the sub-directory of the one that you just made. Now move all files and folders that were generated into the one you made and delete the sub-directory cordova created.

 If more control is needed, create a custom adaptor and assign it to the spy object.

 Now add script commands as explained in the subsequent note:

- See "Configuration" section below for more about configuring.
- See also "Adding and Removing Spies" section below on how to execute.

## Setup

### Configuration

After spypkg is installed, a configuration property `spypkg` is required in the host project's `package.json` file. This property is automatically added as a stub on post install. Below is a configuration progressing from its most simplest form to more complex forms:

```json
{
  "spypkg": {
    "spies": [
      "ionic"
    ],
    "location": "/home/marc/.spies"
  }
}
```

```json
{
  "spypkg": {
    "projectOutPath": "out",
    "spies": [
      "ionic"
    ],
    "location": "C:\\Users\\marc\\AppData\\Roaming\\.spies"
  }
}
```

```json
{
  "spypkg": {
    "projectOutPath": "out",
    "spies": [
      {
        "name": "ionic",
        "adaptor": "build/dist/ionic-adaptor.js"
      },
      {
        "name": "cordova",
        "location": "C:\\Program Files\\nodejs\\bin",
      },
    ],
    "location": "C:\\Users\\marc\\AppData\\Roaming\\.spies"
  }
}
```

```json
{
  "spypkg": {
    "projectOutPath": "out",
    "spies": [
      "npm:*"
      {
        "name": "ionic",
        "adaptor": "build/dist/ionic-adaptor.js",
        "location": "{yarn global dir}"
      },
      {
        "name": "cordova",
        "location": "C:\\Users\\marc\\AppData\\Roaming\\.spies\\v8",
      },
    ],
    "location": "C:\\Users\\marc\\AppData\\Roaming\\.spies"
  }
}
```

- See also "Adding and Removing Spies" section below on how to execute.

### Adding and Removing Spies

After installing spypkg, the following convenience commands will be added to the host project's `package.json`:

```json
{
  "scripts": {
    "add-spies": "node ./node_modules/spypkg --verbose",
    "remove-spies": "node ./node_modules/spypkg --remove --verbose"
  }
}
```

For POSIX systems, by default ```hash -r``` is executed post adding and removing. `--no-hash-reset` switch prevents resetting.

#### `add-spies [--verbose] [--force] [--no-hash-reset]`

When `add-spies` command is executed, spypkg will load the configuration object from the host project's package.json file.  It will then iterate the spies array and deploy shell script files to the value specified in the `location` property. If the `force` switch present, spypkg will remove all spies listed in the configuration object before deploying.

If the optional `projectOutPath` property is declared and directory doesn't exist, it will be created. Otherwise spypkg will redirect execution to its own dist directory. The intention of `projectOutPath` is to give developers more control of files if needed instead of modifying spypkg's dist directory. And if an `adaptor` property is given, it will copy it to the now required `projectOutPath` directory.

#### `remove-spies [--verbose] [--remove] [--no-hash-reset]`

When `remove-spies` command is executed, spypkg will load the configuration object and remove only the declared spies located in `location` directories.

- See "Configuration" section above for more about configuring.

## spypkg-harness

A test harness for spypkg in the form of smoke testing. See it's [README.md](https://github.com/marckassay/spypkg/blob/master/harness/README.md) for more.
<file_sep>/lib/dependency/dependency
#!/bin/sh

# SPYPKG HAS GENERATED THIS FILE!
# If the above comment is modified or removed, 'spypkg' will not delete this file.
currentdir=$PWD
filename=$(basename "$0")
args="$@"

if [ -f $currentdir/package.json ]; then
  outpath=$(node -pe "JSON.parse(process.argv[1]).spypkg.projectOutPath" "$(cat $currentdir/package.json)")

  if [ "$outpath" = "undefined" ]; then
    outpath=node_modules/spypkg/dist/lib/adaptor
  fi

  if [ -d "$currentdir/$outpath" ]; then
    adaptorfullpath=$currentdir/$outpath/$filename-adaptor.js
  else
    echo "'spypkg' was unable to find '$outpath' directory."
    echo "Remove the following file if not needed: "$0
    exit 1
  fi

  if [ ! -f "$adaptorfullpath" ]; then
    adaptorfullpath=$currentdir/$outpath/adaptor.js
  fi
  # TODO adds this echo back when silent switch is implemented.
  # echo "[spypkg] Executing: node " "$adaptorfullpath" "$filename $@"
  node "$adaptorfullpath" "$filename $@"

  ret=$?

  exit $ret
else
  echo "'spypkg' was unable to load 'package.json' in the current directory for '"$filename"' command."
  echo "Remove the following file if not needed: "$0
  exit 1
fi
<file_sep>/lib/adaptor/npm-adaptor.ts
#!/usr/bin/env node

import * as child from 'child_process';
import { promisify } from 'util';
import { SpawnOptions } from 'child_process';

const spawn = <(command: string, opts: SpawnOptions) => Promise<child.ChildProcess>>promisify(child.spawn);

const options: SpawnOptions = {
  stdio: 'inherit',
  shell: true
};

interface NPMExpressionShape {
  exe?: string;
  run?: string;
  command: string;
  pkgdetails?: string;
  options?: string;
}

/**
 * @external https://regex101.com/library/IwsYRm
 */
const regex = new RegExp([
  '^(?<exe>npm)?\\ ?',
  '(?<run>(?<=\\k<exe> )run(?:-script)?)?\\ ?',
  '(?<command>(?<=\\k<run> )[a-z]+(?:[-|:|.|_][a-z]+)?|(?<!\\k<run> )[a-z]+(?:[-][a-z]+)?)?\\ ?',
  '(?<pkgdetails>[a-z0-9\\>\\=\\:\\+\\#\\^\\.\\@\\/][a-z0-9\\>\\=\\:\\+\\#\\^\\~\\.\\@\\/\\-]+)?\\ ?',
  '(?<options>(?:\\ [-]{1,2}[a-zA-Z]+(?:[-][a-z]+)*)*)?$'
].join(''));

function getIsoMorphedExpression(npmex: NPMExpressionShape): string {
  const commmandsEquivalenceTable = {
    'install': 'add',
    'i': 'add',
    'uninstall': 'remove',
    'view': 'info'
  }

  const optionsEquivalenceTable = {
    '--no-package-lock': '--no-lockfile',
    '--production': '',
    '--save': '',
    '--save-prod': '',
    '-P': '',
    '--save-dev': '--dev',
    '-D': '--dev',
    '--save-optional': '--optional',
    '-O': '--optional',
    '--save-exact': '--exact',
    '-E': '--exact',
    '--global': ''
  };

  const map = (table, value: string | string[]) => {
    if (typeof value === "string") {
      value = [value];
    }
    return value.map((val) => {
      // if the mapped val is undefined, return original val
      const mappedval = table[val];
      return (mappedval !== undefined) ? mappedval : val;
    });
  };

  const macromap = () => {
    if (npmex.command) {
      npmex.command = map(commmandsEquivalenceTable, npmex.command)[0];
    }
    options = map(optionsEquivalenceTable, options);
  };

  npmex.exe = 'yarn';

  let options: string[] = (npmex.options) ? npmex.options.split(' ').filter(value => value.length) : [];

  if (npmex.command && !npmex.pkgdetails) {
    if (npmex.command === 'update' && options.indexOf('--global') !== -1) {
      npmex.command = 'global upgrade';
      options = options.filter((value) => value !== '--global');
    } else if (npmex.command === 'rebuild') {
      npmex.command = 'add';
      options.push('--force');
    } else if (options.length) {
      if (options.indexOf('--no-package-lock') === -1) {
        npmex.command = map(commmandsEquivalenceTable, npmex.command)[0];
      } else {
        npmex.command = 'install';
      }
      options = map(optionsEquivalenceTable, options);
    }
  } else if (npmex.command && npmex.pkgdetails) {
    if (npmex.command === 'install' && options.indexOf('--global') !== -1) {
      npmex.command = 'global add';
      options = options.filter((value) => value !== '--global');
    } else if (npmex.command === 'version') {
      options = ['--' + npmex.pkgdetails];
      npmex.pkgdetails = '';
    } else {
      macromap();
    }
    macromap();
  }

  return [npmex.exe, npmex.run, npmex.command, npmex.pkgdetails]
    .concat(options)
    .filter((value) => {
      if (value != '' || value != undefined) {
        return value;
      }
    })
    .join(' ');
};

const exe = async (npmExpression, yarnExpression) => {
  if (verboseEnabled) {
    console.log('[spypkg] The npm expression below has been transformed into the followed yarn expression:');
    console.log(npmExpression);
    console.log(yarnExpression);
  }

  return await spawn(yarnExpression, options)
    .then((onfulfilled) => {
      if (onfulfilled.stdout) {
        console.log(onfulfilled.stdout);
      }
      if (onfulfilled.stderr) {
        console.log(onfulfilled.stderr);
      }
    })
    .catch((reason) => {
      console.log(reason);
    });
};

let verboseEnabled: boolean;
let argument: string = '';

// prepare argv values into argument, so that regex can parse as expected
for (let j = 2; j < process.argv.length; j++) {
  if (process.argv[j] === '--verbose') {
    verboseEnabled = true;
  } else {
    argument += ' ' + process.argv[j];
  }
}
argument = argument.trimLeft();

let prefix: string = (process.platform === 'win32') ? 'cmd /c' : '';

const parsedArg: NPMExpressionShape = regex.exec(argument)['groups'];

const transformedExpression: string = getIsoMorphedExpression(parsedArg);

exe(argument, (prefix + ' ' + transformedExpression).trimLeft());
