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 Rule
s. 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
Rule
s are basic interfaces with:
- a
name
- an optional
plan
function - an optional
check
function - an optional list of
dependencies
- an optional list of
devDependencies
in a nut-shell, that's it. counsel is a small set of functions that run these
Rule
s 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)Error
s 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
- filename-format - enforces a filename-format convention
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
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
-
- see it used in this project, here
similar works
-
- 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! :)
- counsel is very similar to builder, but counsel doesn't need to be yet-another-task-runner. you can