getting started

install

yarn add --dev counsel

alternatively, npm install --save-dev counsel

usage

conventional usage is to add a .counsel.ts file to your project root dirname.

you can have counsel insert a generic .counsel.ts file for you using --init:

$ counsel --init
info: ⚙️ config file .counsel.ts created successfully

alternatively, as shown next, we can bootstrap our own counsel.ts file.

once a project has a counsel file, run various counsel commands:

  • npx counsel apply

  • npx counsel check

npx counsel --help is also there to help!

concepts

counsel has only one major concept to understand--the Rule. counsel can apply rules and check that rules are enforced. counsel rules are specified using a .counsel.ts file, hereby "counsel file." let's look at counsel files and rules next.

counsel file

the counsel file declares and exports Rules. the only expectation is that it exports a function named create with following signature:

ContextWithRules => ContextWithRules

let's create a basic rule that enforces that the project has a readme file:

// .counsel.ts
export const assertReadmeExists: Rule = {
  name: 'assert-readme-exists',
  check: async ({ fs, path, ctx: { projectDirname } }) => {
    const filename = path.resolve(projectDirname, 'readme.md')
    const isReadable = await fs.lstat(filename).catch(() => false)
    if (!isReadable) throw new Error('readme.md file missing')
  }
}

// export your rules via a `create` function
export function create (opts: ContextWithRules) =>
  ({ ...opts, rules: [assertReadmeExists] })

create, import, and use as many rules as desired. rules can be used for all sorts of reasons. sky is the limit.

rule

Rules are basic interfaces with:

  1. a name
  2. an optional plan function
  3. an optional check function
  4. an optional list of dependencies
  5. an optional list of devDependencies

in a nut-shell, that's it. counsel is a small set of functions that run these Rules against your project.

here's a simple rule that exercises some of the rule api:

export const exampleRule: Rule = {
  name: 'example-rule',
  plan: ({ ctx }) => {
    console.log(
      `planning to add keyword 'example' to pkg: ${ctx.packageJson.name}`
    )
    return () => {
      ctx.packageJson.keywords = ctx.packageJson.keywords || []
      ctx.packageJson.keywords.push('example')
    }
  },
  check: async ({ ctx: { packageJson } }) => {
    const keywords = packageJson.keywords || []
    console.log(`existing keywords: ${keywords.join(' ')}`)
    const keywordExists = keywords.find(val => val === 'example')
    if (!keywordExists) throw new Error("'example' keyword missing")
  },
  devDependencies: [{ name: 'debug', range: '*' }]
}

rule.name

every rule requires a name. it must always be a string.

rule.plan

a plan returns a function or null, which we call a Migration. a Migration is responsible for changing the project in some way. rather than mutating the project upfront, all changes to a project are encouraged to happen in the Migration. this gives the user an opporitunity to opt-out of rules in counsel's interactive mode.

for example, here's a simplified version of counsel's baked in copy rule:

export interface CopyRule {
  src: string
  dest: string
}
const plan = (opts: TaskPayload<CopyRule>) =>
  () => fs.copy(opts.rule.src, opts.rule.dest)

the () => fs.copy(...) matches the Migration type, so it should be set! plan receives a TaskPayload as input, covered later.

export type Migration =
  null // return null when there is nothing to migrate
  | (() => void | Promise<void>) // otherwise, migrate in a returned function

rule.check

check recieves a TaskPayload as is responsible for ensuring that a rule is enforced. we've already seen a few examples of check functions:

check functions should:

  • be synchronous, or return a promise
  • throw (or reject) Errors when a violation is detected
  • tend to be lenient

on the topic of leniency, consider counsel's baked in ScriptRule. if you wanted a rule to provide a default npm script named test, where the test command was node test/index.js, consider if the project added a timeout flag, such as "test": "node test/index.js --timeout 10s".

it would be a bad user experience to throw if the script did not strictly equal node test/index.js. adding a simple flag is likely something that rule implementer would be OK with. more imporantly, the core intent of the rule is likely to assert that the user has written tests. a better check implementation would be to ensure that a test script is present, and is truthy (i.e. runs some test script). enforcing rules at any given granularity is something that needs to be worked through with rule makers and their teams. be weary of agitating consumers by implementing overly strict checks.

rule.dependencies

rules can request dependencies & devDependencies to be installed. dependencies are always requested in a range format:

const installRule: Rule = {
  name: 'install-koa',
  dependencies: [
    { name: 'koa', range: '^2' }
  ],
  devDependencies: [
    { name: 'node-fetch': range: '*' }
  ]
}

by using semver ranges, you can pin dependencies with moderate precision or flexibility.

typings

it is worth brief mention that the majority of counsel's interfaces/typings are packed nicely into a < 100 LOC file here, for your viewing.

TaskPayload

plan and check receive a task payload as input. the payload is rich with data and async functions to help plan and check. check out the typings in the source code (1, 2).

batteries

counsel exports a handful of common and helpful rules. batteries included!

see counsel.rules, or src/rules to see a handful. at the time of writing, these default rules include:

copy

  • copy - copies files or folders into a project
import { rules } from 'counsel'
const { plan } = rules.copy
const rule: CopyRule = {
  name: 'copy-markdown-file-test',
  src: path.resolve(__dirname, 'readme-template.md'),
  dest: path.resolve(ctx.projectDirname, 'readme.md'),
  plan
}

filename-format

import { kebabCase } from 'lodash'
import { rules } from 'counsel'
const { check } = rules.filenameFormat

const rule: FilenameFormatRule = {
  name: 'test-filename-rule',
  filenameFormatExtensions: ['js'],
  filenameFormatExclude: ['coffee'],
  filenameFormatFunction: kebabCase,
  check
}
// test-file.js // ok
// functional-module.js // ok
// SomeFile // not ok

githook

  • githook - installs githook support via husky into a project
import { rules } from 'counsel'
const { create } = rules.githook

const rule: GitHooksRule = create({
  name: 'lint-on-commit',
  hooks: {
    'pre-commit': 'yarn lint'
  }
})

readme

  • readme - enforces that a project has a readme file
import { rules } from 'counsel'
const { rule } = rules.readme

script

  • script - installs a npm script to a project
import { rules } from 'counsel'
const { create } = rules.script
const rule: criptRule = create({
  name: 'add-test-script-rule',
  scriptName: 'test',
  scriptCommand: 'tape test/blah.js'
})

examples

similar works

  • FormidableLabs/builder

    • counsel is very similar to builder, but counsel doesn't need to be yet-another-task-runner. you can npx counsel apply, never fully install it, and reap many of it's benefits.
    • builder also claims flexibility and an anti-"buy the farm" attitude. in practice, we've observed the opposite. feel free to try both! :)