Skip to content

Composability

This chapter shows how to test generators, use them programmatically in CLI tools and existing applications and how to write your own reusable tasks.

Running other generators

Another generator can be directly imported and run like any other tasks:

ts
import { PinionContext, exec } from '@featherscloud/pinion'
import { generate as generateReadme } from './readme.tpl'

interface Context extends PinionContext {}

export function generate(init: Context) {
  return Promise.resolve(init)
    // Initialize a new NodeJS project
    .then(exec('npm', ['init', '--yes']))
    // Generate the readme
    .then(generateReadme)
}
ts
import { PinionContext, renderTemplate, toFile } from '@featherscloud/pinion'

// A Context interface. (This one is empty)
interface Context extends PinionContext {}

// The file content as a template string
function readme() {
  return `# Hello world

This is a readme generated by Pinion

Copyright (c) ${new Date().getFullYear()}
`
}

export function generate(init: Context) {
  return Promise.resolve(init).then(renderTemplate(readme, toFile('readme.md')))
}
sh
npx pinion generators/app.tpl.ts

The runGenerators tasks also allows to run all .tpl.ts generators in a folder in alphabetical order using the current context.

ts
// Get current file and directory in an ES module
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
import { PinionContext, runGenerators } from '@featherscloud/pinion'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

interface Context extends PinionContext {}

// A `generate` export that wraps the context in a promise and renders it.
export function generate(init: Context) {
  return Promise.resolve(init).then(runGenerators(__dirname, 'app'))
}
ts
import { PinionContext, exec } from '@featherscloud/pinion'

interface Context extends PinionContext {}

// A `generate` export that wraps the context in a promise and renders it.
export function generate(init: Context) {
  return Promise.resolve(init)
    // Initialize a new NodeJS project
    .then(exec('npm', ['init', '--yes']))
}
ts
import { PinionContext, renderTemplate, toFile } from '@featherscloud/pinion'

// A Context interface. (This one is empty)
interface Context extends PinionContext {}

// The file content as a template string
function readme() {
  return `# Hello world

This is a readme generated by Pinion

Copyright (c) ${new Date().getFullYear()}
`
}

export function generate(init: Context) {
  return Promise.resolve(init).then(renderTemplate(readme, toFile('readme.md')))
}

Embedability

Since a generator is just a function that can be called anywhere, Pinion generators can be composed, tested or used programatically e.g. in your own CLI tools just by importing the file, initialising a context and calling the generator:

ts
import { getContext } from '@featherscloud/pinion'
import { Context, generate } from './my-generator.tpl.ts'

async function main() {
  const context = getContext({
    // You can add additional inital context values here
  }) as Context

  return generate(context)
}

main()
sh
npx pinion generators/app.tpl.ts

Testing

Similar to an embedded generator, automated tests can be written by initializing the context - usually with a temporary path - and passing all context variables necessary to skip user prompts:

ts
import { describe, it } from 'node:test'
import assert from 'node:assert'
import path from 'node:path'
import os from 'node:os'
import fs from 'node:fs/promises'
import { getContext } from '@featherscloud/pinion'
import { Context, generate } from '../generators/readme.tpl'

describe('readme generator tests', () => {
  it('generates a readme with name and description', async () => {
    // Create a temporary directory
    const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'pinion-test-'))
    // Initialize the context with all generator values
    const init = getContext<Context>({
      name: 'My test app',
      description: 'This is the description for the test app',
      cwd
    })

    // The final context with all values can be used to make assertion
    // about what ran
    const context = await generate(init)

    assert.strictEqual(context.name, 'My test app')
    // Check if the readme file exists
    assert.ok(fs.stat(path.join(cwd, 'readme.md')))
  })
})
ts
import {
  PinionContext,
  prompt,
  renderTemplate,
  toFile
} from '@featherscloud/pinion'

// Setup the Context to receive user input
export interface Context extends PinionContext {
  name: string
  description: string
}

// The template uses Context variables.
function readme({ name, description }: Context) {
  return `# ${name}

> ${description}

This is a readme generated by Pinion

Copyright (c) ${new Date().getFullYear()}
`
}

export function generate(init: Context) {
  return Promise.resolve(init)
    // Ask prompts (using Inquirer)
    .then(
      prompt((context) => {
        // Only ask question if `name` or `description` are not passed
        return {
          name: {
            type: 'input',
            message: 'What is the name of your app?',
            when: !context.name
          },
          description: {
            type: 'input',
            message: 'Write a short description',
            when: !context.description
          }
        }
      })
    )
    // Render the template
    .then(renderTemplate(readme, toFile('readme.md')))
}
sh
node --import tsx --test test/readme.tpl.test.ts

Reusable tasks

Reusable task like Pinion's built in tasks can take parameters that are either a plain value or a callback based on the context. They can be written like this:

ts
import {
  Callable,
  PinionContext,
  getCallable,
  toFile
} from '@featherscloud/pinion'

export function sayHello<C extends PinionContext>(nameParam: Callable<string, C>, fileParam: Callable<string, C>) {
  return async (ctx: C) => {
    // Get the actual name value
    const name = await getCallable(nameParam, ctx)
    // Get the filename
    const fileName = await getCallbable(fileParam, ctx)

    console.log(`I may write your name "${name}" to file ${fileName}`)

    return ctx
  }
}

export function generate(init: PinionContext) {
  return Promise.resolve(init).then(sayHello('David', toFile('hello', 'dave.md')))
}

The pinion Property

When you extend PinionContext, your Context's pinion property will contain the following object:

ts
export interface Configuration {
  /**
   * The current working directory
   */
  cwd: string
  /**
   * The logger instance, writing information to the console
   */
  logger: Logger
  /*
   * Whether to force overwriting existing files by default
   */
  force: boolean
  /**
   * The prompt instance, used to ask questions to the user
   */
  prompt: typeof prompt
  /**
   * Trace messages of all executed generators
   */
  trace: PinionTrace[]
  /**
   * A function to execute a command
   *
   * @param command The command to execute
   * @param args The command arguments
   * @param options The NodeJS spawn options
   * @returns The exit code of the command
   */
  exec: (
    command: string,
    args: string[],
    options?: SpawnOptions
  ) => Promise<number>
}

These properties can be used inside of a task to e.g. log information or ask their own prompts.

What's next

The API documentation has a full list of all available generator tasks and types.