Skip to content

Latest commit

 

History

History
573 lines (439 loc) · 23.4 KB

dev-guide.md

File metadata and controls

573 lines (439 loc) · 23.4 KB

Stripes CLI Developer's Guide

Introduction

The Stripes CLI is a command-line interface that runs using Node. It enhances the default build and serve operations found within stripes-core's Node API. It does this by modifying the Webpack configuration as needed.

Stripes CLI uses the Yargs framework for defining commands and Inquirer for accepting interactive input. In addition to providing a convention for defining commands and options, Yargs offers great built-in help.

Development installation

To develop Stripes CLI, first clone the repo and then yarn install its dependencies:

$ git clone https://github.com/folio-org/stripes-cli.git
$ cd stripes-cli
$ yarn install

Then create a link to lib/stripes-cli.js in your path so stripes can easily be run from anywhere.

$ ln -s ./lib/stripes-cli.js /usr/local/bin/stripes

Running tests

The CLI's tests use Mocha, Chai, and Sinon. Run the test with the test script:

$ yarn test

Code organization

The main CLI directories:

stripes-cli
├─doc          Documentation
├─resources    Workspace template files
├─test         CLI tests
└─lib
  ├─cli        CLI context, middleware, and common logic
  ├─commands   Command handlers
  ├─okapi      Okapi services and http client
  └─platform   Platform generation logic

NOTE: Template files for creating new UI Modules via app create and setting up BigTest via app bigtest are retrieved from https://github.com/folio-org/ui-app-template

Commands

All commands are organized in the lib/commands directory. A command consists of Yargs command module that exports:

  • command - String of the command name and any positional arguments
  • describe - Description of the command
  • builder - Function accepting and returning a Yargs instance for defining options, examples, and applying middleware
  • handler - Function invoked with a parsed argv to perform the command

Example command:

// The command itself
function myCommand(argv) {
  console.log(`Hello ${argv.name}!`);
}

// Yargs command module with a builder function
module.exports = {
  command: 'hello',
  describe: 'A very basic command',
  builder: (yargs) => {
    yargs
      .option('name', {
        describe: 'A name to say hello to',
        type: 'string',
      })
      .example('$0 hello --name folio', 'Say hello to "folio".');
  },
  handler: myCommand,
};

Complex logic or logic consumed by more than one command should be kept in separate modules. Although not a strict requirement, try to limit user input and output to the command handler itself. This allows the work to be shared in different contexts where the messaging may differ across commands or use-cases.

Options

Options are defined using Yargs option syntax.

Example:

port: {
  type: 'number',
  describe: 'Development server port',
  default: 3000,
  group: 'Server Options:',
},

Useful settings include:

  • type - option type (string, boolean, number, array)
  • describe - description for help
  • default - value when the option is not provided
  • group - grouping in the help output
  • choices - limit validation to predefined values
  • conflicts - options that must not be set with this one

At minimum, include type and describe properties for all options help populate the CLI's built-in help output and command refrence. See the Yargs .options API documentation for all available settings.

In the command's builder, apply options with .option():

command: 'hello',   
builder: (yargs) => {
  yargs
    .option('name', {
      describe: 'A name to say hello to',
      type: 'string',
    });
},
handler: myCommand,

Options used in more than one command should be kept in lib/commands/common-options. Organize and export them in logical groupings, then import the desired options in each command. Doing so consolidates the option metadata, so option descriptions and types remain consistent across the application. When assigning multiple options at a time, pass a single object to .options() with Object.assign().

builder: (yargs) => {
  .options(Object.assign({}, okapiOptions, serverOptions);
},

Positionals

Positional arguments are defined similar to options via the command builder. However, they should also include a reference within the command: value to define their order. Required positionals are in the form <name> while optional positionals use [name]. See the Yargs positional documentation for more information.

command: 'hello <name>',
builder: (yargs) => {
  yargs
    .positional('name', {
      describe: 'A name to say hello to',
      type: 'string',
    });
},
handler: myCommand,

Middleware

The CLI supports middleware for additional handling of argv prior to invoking a command. One or more middleware functions can applied to the Yargs builder. See the Yargs middleware documentation for more details.

Several useful middleware functions are included with the CLI for loading context, parsing standard input, and prompting the user for input.

CLI context

The CLI can provide a context for each command which denotes whether the command has been run from a UI module, platform, or workspace directory. This is helpful for performing operations specific to specific contexts. To access to this information in your command, apply the contextMiddleware to your command builder. The result will be applied to argv.context.

// Lazy load to improve startup time
const importLazy = require('import-lazy')(require);
const { contextMiddleware } = importLazy('../cli/context-middleware');

// The command itself
function myCommand(argv) {
  if(argv.context.isUiModule) {
    console.log(`Hello from the module ${argv.context.moduleName}!`)
  } else {
    console.log(`Hello from somewhere else!`);
  }
}

// Yargs command module with a builder function
module.exports = {
  command: 'hello',
  describe: 'A very basic command',
  builder: (yargs) => {
    yargs
      .middleware([
        contextMiddleware(),  // <--- middleware
      ])
      .example('$0 hello', 'Say hello to from context.');
  },
  handler: myCommand,
};

Stripes config

Use the stripesConfigMiddleware when a Stripes tenant configuration needs to be accessed within a command. This middleware will load the configuration from file (typically stripes.config.js, but .json is also supported) when --configFile is specified on the command-line. Alternatively, the stripes configuration can be read from stdin if no --configFile is specified and a JSON string is piped into the command. The stripes configuration, whether by file or stdin, is made available to the command as argv.stripesConfig.

When using stripesConfigMiddleware, apply stripesConfigFile and/or stripesConfigStdin options from common-options. This will ensure both configFile and stripesConfig are reported consistently in the help. Commands consuming a config file, typically accept configFile as a positional option.

// Lazy load to improve startup time
const importLazy = require('import-lazy')(require);
const { stripesConfigMiddleware } = importLazy('../cli/stripes-config-middleware');
const { stripesConfigFile, stripesConfigStdin } = importLazy('./common-options');

// The command itself
function myCommand(argv) {
  console.log(`Hello ${argv.configFile}`);          // <--- filename
  console.log(argv.stripesConfig);                  // <--- config object
}

// Yargs command module with a builder function
module.exports = {
  command: 'hello [configFile]',                    // <---- indicate placement of positional
  describe: 'A very basic command',
  builder: (yargs) => {
    yargs
      .middleware([
        stripesConfigMiddleware(),                  // <--- middleware
      ])
      .positional('configFile', stripesConfigFile.configFile)   // .positional() does not accept an object
      .options(stripesConfigStdin);                             // .options() will accept an object
      .example('$0 hello stripes.config.js', 'Say hello to a stripes configuration.');
  },
  handler: myCommand,
};

Standard input

To accept standard input (stdin) within a command, apply one of the CLI's stdin middleware handlers from lib/cli/stdin-middleware.js. Available stdin middleware include stdinStringMiddleware, stdinArrayMiddleware, and stdinJsonMiddleware for parsing string, array, and JSON input. The stdinArrayMiddleware splits on whitespace, including line breaks, to make accepting multi-line input easy.

Each of the CLI's stdin middleware accept a key and return the middleware function for use by Yargs. When the middleware is invoked, stdin will be parsed and, if available, assigned to the specified option key. From within the command, simply access the value as you would any other option.

For example, the following will assign stdin, parsed as an string, to the name option. For consistency, include "(stdin)" in your option's description to surface this consistently in the CLI's generated documentation.

// Lazy load to improve startup time
const importLazy = require('import-lazy')(require);
const { stdinStringMiddleware } = importLazy('../cli/stdin-middleware');

// The command itself
function myCommand(argv) {
  console.log(`Hello ${argv.name}!`);
}

// Yargs command module with a builder function
module.exports = {
  command: 'hello',
  describe: 'A very basic command',
  builder: (yargs) => {
    yargs
      .middleware([
        stdinStringMiddleware('name'),  // <--- provide the option key to assign stdin to
      ])
      .option('name', {
        describe: 'A name to say hello to (stdin)', // <--- include "(stdin)" in the description for the doc generator
        type: 'string',
      })
      .example('$0 hello --name folio', 'Say hello to "folio".');
      .example('echo folio | $0 hello', 'Say hello to "folio" with stdin.');
  },
  handler: myCommand,
};

Interactive input

When answers to questions can be acquired up front, the simplest way to ask for them is to apply the CLI's promptMiddleware from lib/cli/prompt-middleware. When invoked, this middleware will check the incoming argv prompt the user for any options which were not provided on the command line. Internally the middleware uses Inquirer to prompt the user with questions.

This example will prompt for a name before running command's hander:

// Lazy load to improve startup time
const importLazy = require('import-lazy')(require);
const { promptMiddleware } = importLazy('../cli/prompt-middleware');

// The command itself
function myCommand(argv) {
  console.log(`Hello ${argv.name}!`);
}

// Used to share option details between promptMiddleware and yargs builder
const myOptions = {
  name: {
    describe: 'A name to say hello to',
    type: 'string',
  }
}

// Yargs command module with a builder function
module.exports = {
  command: 'hello',
  describe: 'A very basic command',
  builder: (yargs) => {
    yargs
      .middleware([
        promptMiddleware(myOptions),  // <--- provide object of option(s) for user prompts
      ])
      .option('name', myOptions.name)
      .example('$0 hello', 'Prompt for name and then say hello.');
  },
  handler: myCommand,
};

Yargs options and Inquirer questions do not have fully compatible structures. When a CLI option is also used as an interactive question, avoid duplication by using the CLI's yargsToInquirer() helper. This is automatically invoked by promptMiddleware.

Any Inquirer question settings that do not have a Yargs option equivalent can be defined in an inquirer property. In the following example, Yargs has no equivalent for the password type or mask setting. The yargsToInquirer() helper will apply any inquirer-specific options after conversion.

password: {
  type: 'string',
  describe: 'Okapi tenant password',
  group: 'Okapi Options:',
  inquirer: {
    type: 'password',
    mask: '*',
  },
},

Grouping

Related commands can be grouped together using directories. To do this create a directory to contain the related commands and create a command to reference the directory.

Here we have mod.js the command, and mod the directory:

stripes-cli
└─lib
  └─commands
    ├─mod.js
    └─mod
      ├─add.js
      ├─remove.js
      ├─update.js
      ├─enable.js
      └─disable.js

Using Yarg's .commandDir(), the command instructs Yargs to retrieve all commands found in the mod directory. No handler is necessary if mod does nothing on its own.

module.exports = {
  command: 'mod <command>',
  describe: 'Commands to manage UI module descriptors',
  builder: yargs => yargs.commandDir('mod'),
  handler: () => {},
};

The resulting commands from above are all accessible by mod followed by the command name. This gives the appearance of sub-commands under mod. For example:

$ stripes mod add
$ stripes mod remove

Yargs will surface descriptions for each command in the mod directory with the help output for stripes mod --help.

Logging

Logging is instrumented with the debug utility. All logs within the CLI pass through lib/cli/logger.js, a wrapper around debug, to ensure proper namespace assignment.

To add a logger to code, require and invoke it:

const logger = require('./cli/logger')();
logger.log('a message');

Optionally, pass the name of a feature or category when invoking the logger. This is useful for filtering log output.

const okapiLogger = require('./cli/logger')('okapi');
okapiLogger.log('a message about Okapi');

See debugging below for details on viewing log output.

Okapi Client

TODO: Document

Plugins

The CLI can be extended with plugins. Plugins provide a means for the user to perform custom logic, possibly altering the Webpack configuration prior to invoking a Webpack build. They are defined in a .stripesclirc.js configuration file.

To create a plugin, define a plugins object in .stripesclirc.js which contains keys representing each command that is receiving a plugin. In this example, a plugin has been defined for serve:

module.exports = {
  port: 8080,
  plugins: {
    serve: servePlugin,
  },
};

The value should be an object containing beforeBuild and, optionally, options.

  • beforeBuild is a function that will be passed the command's parsed argv. It should return a function that will be passed Webpack config processed by the CLI. This gives the opportunity for the plugin to inspect or modify the config prior to running Webpack.
  • options define additional Yargs options for the command. When provided, options will be validated and included in the command help along the CLI's built-in options.
const servePlugin = {
  options: {
    example: {
      describe: 'This will show up in the help',
      type: 'string',
    },
  },
  beforeBuild: (argv) => {
    return (config) => {
      // Chance to inspect or modify the config based on argv...
      return config;
    };
  },
}

Documentation

The best way to document the CLI is within each Yargs command module. Be sure to include a description for the command, options, and positionals. Include type for options and positionals.

Group options where it make sense using the group property. This breaks out options in the help for readability. Custom option groups should end with the word "Options:", such as "Server Options:", in order to be picked up by the CLI document generator.

module.exports.serverOptions = {
  port: {
    type: 'number',
    describe: 'Development server port',
    default: 3000,
    group: 'Server Options:',
  },
  // ...
}

Note: If your command is a work in progress, experimental, or has an interface that is likely to change, include "(work in progress)" in the description. This will be highlighted in the command documentation and TOC.

Add one or more examples on how to use the command by calling .example() in the Yargs builder. $0 within the example string is replaced by the script name (stripes) in the help output:

builder: (yargs) => {
  yargs
    .example('$0 hello', 'Say hello')
    .example('$0 hello --name folio', 'Say hello to "folio".');
  // ...
},

Generating the command reference

After creating a new command or updating an existing one, be sure to update docs/commands.md, the CLI's command reference. This process is automated by the lib/doc/generator.js script. To update it, run:

$ yarn docs

This will traverse the CLI's commands gathering all the --help text and parsing to write as markdown. The generated markdown help is then applied to the docs/commands-template.md. If changes are needed to the introduction or footer, update docs/commands-template.md before running yarn docs.

Note: Review the generated changes with the actual help output checking for unexpected additions or omissions. These may be a sign that the command reference was not updated recently or that Yargs has changed its help text formatting. If it appears to be the later, review docs/yargs-help-parser.js for corrections.

Table of Contents

When updating documentation like this dev-guide.md or the user-guide.md, keep the table of contents (TOC) updated as well. The TOC will need updating anytime a heading is added, removed, or changed. When modifying an existing heading, kee.

The Okapi repository has a handy script, md2toc, to help with maintaining the TOC. In most cases the -l 2 option will apply. For example, the following will generate a TOC for which can then be applied to this document.

$ perl ../okapi/doc/md2toc -l 2 ./doc/dev-guide.md

Debugging

Stripes-CLI implements debug for diagnostic logging. This can be a useful starting point to diagnose errors.

Debug output is enabled by setting the DEBUG environment variable. The value of DEBUG is a comma-separated list of namespaces you wish to view debug output for. By convention, namespaces match the supporting package name. Features within a namespace may be separated by a colon. The wildcard * is supported. For Windows, replace export with set in the examples below.

For example, to view all stripes-cli debug logs:

$ export DEBUG=stripes-cli*

To view only the cli's calls to Okapi:

$ export DEBUG=stripes-cli:okapi

To view all stripes-cli and stripes-core debug logs:

$ export DEBUG=stripes-cli*,stripes-core*

Alternatively set the wildcard on stripes:

$ export DEBUG=stripes*

It is also possible set the wildcard for all namespaces:

$ export DEBUG=*

Note: The above will enable logging for all packages that happen to be instrumented with debug, including express and babel.

Some of the available diagnostic output can be lengthy. The debug utility writes to stderr, so if you would like to send this content in a file, you can do so with:

$ export DEBUG=stripes*
$ stripes serve 2> file.log

Visual Studio Code

Included in the Stripes-CLI repository is a Visual Studio Code launch.json configuration which makes debugging a command or Stripes build easy. This file contains the debug configuration of several sample CLI commands as well as the CLI's own unit tests.

Example configuration:

{
  "type": "node",
  "request": "launch",
  "name": "Serve from PLATFORM",
  "program": "${workspaceFolder}/lib/stripes-cli.js",
  "args": [ "serve", "stripes.config.js"],
  "cwd": "${workspaceFolder}/../stripes-sample-platform"
},

Pay careful attention to the current working directory, cwd, defined for each configuration as this may not match an app or platform on your current system. Modify the cwd to a suitable (and often temporary) path. This will be the path in which the CLI is invoked from via VSCode. It is necessary for determining proper context.

Modify the args property to include the command name and any command options desired. For options, separate out the key from the value. For example, --user diku_admin will have two entries in the array, --user and diku_admin.

  "args": ["perm", "create", "module.hello-world.enabled", "--push", "--user", "diku_admin"],

To debug with VSCode, set a breakpoint on the desired command or unit test. For CLI commands, it is often best to start at the top of the handler, for example, in lib/commands/serve.js. Next, from the debug menu, select the appropriate configuration and click play.

VSCode breakpoint

In situations where the handler is not invoked as expected, check your input in args. Also, try adding --no-interactive to ensure the debugger is not improperly handling interactive input. You can always set the breakpoint in lib/stripes-cli.js as the very first point of entry.

Adding breakpoints in Stripes-core

The version of stripes-core in use by the CLI could vary depending on your CLI install, app, platform, or workspace configuration. The easiest way to ensure your stripes-core breakpoints will be hit properly is to initiate debugging in the CLI using the Stripes Serve from PLATFORM or Stripes Serve from APP configuration. Set your breakpoint at the end of the serve command handler where the stripes-core API, stripes.api.serve(...), is invoked.

From there, simply step into the stripes-core code. VSCode will open the version of stripes-core in use. Once a stripes-core file is open, inspect its path, then open and set breakpoints on any other desired files found within the stripes-core's webpack directory.

Releasing

To release Stripes-CLI, follow the general Stripes release procedure. The only CLI-specific addition is to make sure the command reference has been regenerated before tagging. Do this after bumping the version number so the correct version is reflected in the generated documentation.