Skip to content

Latest commit

 

History

History
487 lines (347 loc) · 13 KB

README.md

File metadata and controls

487 lines (347 loc) · 13 KB

Puggle

Automated Tests Coverage Status

A CLI for bootstrapping and keeping project tooling up-to-date.

Table of contents

About

When you work on lots of packages, you end up making project templates to spin up projects faster and faster. The issue arises when you come back to an older project, the tooling is outdated, has vunrebilities or is deprecated. Then you have to spend time refreshing your memory on the setup, manually updating dependencies and re-testing everything again.

I made puggle to solve this problem, to quickly bootstrap a project with the ability to auto-upgrade it later when the template changes.

My idea is that you have your source code then you use puggle to hoist the meta-packages around it. For example, adding prettier or moving from husky to yorkie

How it works

You setup presets in puggle which are a programatic way of bootstrapping directories. This creates a virtual version of your desired directory and then it gets written to disk.

When you come to update a project, puggle re-generates that virtual directory and differentially updates they files it can.

Usage

Using a preset to setup a project

# Install puggle globally
npm install -g puggle

# Initialize a project into a new folder 'new-project'
puggle init new-project

Upon returning to a project later and wanting to update it:

# Go to the project - it will have a puggle.json
cd to/your/project

# Run the update
puggle update

A simple preset

Below is a simple preset, it adds a folder called src and puts an index.js in it.

const { VDir, VFile } = require('puggle')

const indexJs = (name) => `
// App entrypoint
console.log('Hello, ${name}!')
`

module.exports = {
  name: 'robb-j:sample',
  version: '0.1.2',

  plugins: [],

  async apply(root, { targetName }) {
    // Create a virtual file and fill it with a template
    const file = new VFile('index.js', indexJs(targetName))

    // Create a directory and put the file in it
    const dir = new VDir('src', [file])

    // Add the directory (and its file) to the virtual directory
    root.addChild(dir)
  },
}

Design principles

  • Be framework and language agnostic, with implementations built ontop of a common base
  • Be composable with plugins to share functionality
  • Allow seamless-ish upgrades to avoid updating project configs over and over again
  • Be developer agnostics, you have your own presets and I have mine.

Making presets

Here's how to create and test a preset locally

# Create a directory for your preset and go into it
mkdir my-preset
cd my-preset

# Create your preset file
touch index.js

# Make an empty package.json
npm init

# Add puggle as a global dependency
npm i -g puggle

# 1. Edit your preset in index.js
# 2. Set your package name to something
#   - Presets must start with puggle-preset
#   - Preferably user namespaced, e.g. @robb_j/puggle-preset-test
#   - See "Publishing presets" below for more info

# To test locally, link the module
npm link

# Try to run it with puggle and you should see it there
puggle

# Remember to unlink it when you're finished
# -> You have to be in the same directory as your package.json
npm unlink

Virtual files

Puggle works by creating and manipulating virtual files then writting them all to disk at once. There are are several types of files you can make, they all inherit from VNode.

Placeholder vs persist

Nodes are added as either PatchStrategy.placeholder or PatchStrategy.persist, this is used to determine how puggle update works.

  • persist - the value of the virtual file will always overwrite any local changes since puggle init
  • placeholder - any local changes will always be kept

VFile

This is a basic text file, it has a name and its contents as strings and a PatchStrategy. The default strategy is always placeholder, you have to opt-in to persist changes.

const { VFile } = require('puggle')

const fileContents = `
// Some complicated javascript
console.log('Hello, world!')
`

let indexJs = new VFile('index.js', fileContents, PatchStrategy.persist)

VDir

This type of node represents a directory which you can add files to, like in a real file system.

It has a method, #addChild, which you use to add other files/directories to it. You should always use this to add new nodes (it internally sets node.parent).

It has a #find method which you can use to retrieve child nodes, e.g. to look for src/config/init.js.

const { VFile, VDir } = require('puggle')

let dir = new VDir('src', [
  new VFile('.env', 'SECRET=pyjamas'),
  new VFile('.gitignore', '.env'),
  new VDir('src', [new VNode('hello.txt', 'hi')]),
])

// Add a new child
dir.addChild(new VNode('README.md', '> coming soon'))

// Find a child
dir.find('.env')
dir.find('src/hello.txt')

VConfigFile

This represents some form of configuration file, currently json and yaml are supported.

Yaml files can optionally have a comment too which is inserted at the top.

const { VConfigFile, VConfigType } = require('puggle')

const json = new VConfigFile('data.json', VConfigType.json, {
  url: 'https://duck.com',
})

const yaml = new VConfigFile(
  'config.yaml',
  VConfigType.yaml,
  { name: 'geoff' },
  { comment: 'All about geoff' }
)

patches

Config files have two ways of storing their data, there is the initial value you pass to it and patches that can be applied later. This allows you to have both placeholder and persist-ed content in the same file.

When running puggle update it will make sure the persit-ed patches are kept in your file, while the placeholder patches will prefer local changes.

// A base config file with an empty person object
const config = new VConfigFile('data.json', VConfigType.json, {
  person: {},
})

// This patch will be persit on "puggle update"
// -> e.g. If you changes the name to jim, "puggle update" would set it back to geoff
// -> It will merge objects together using lodash.merge
config.addPatch('person', PatchStrategy.persist, { name: 'geoff' })

// This patch will keep local changes after a "puggle init"
// -> e.g. if it was changes to 43, it would still be 43 after a "puggle update"
// -> You can use dot.notation to set values, this uses lodash.get
config.addPatch('person.age', PatchStrategy.palceholder, 42)

VIgnoreFile

This represents an ignore file like a .gitignore. You pass it a set of rules and a friendly comment to explain the file. Also useful for .npmignore, .prettierignore or others.

It automatically merges changes from existing files when doing puggle update.

const { VIgnoreFile } = require('puggle')

let ignore = new VIgnoreFile('.gitignore', 'Files for git to ignore', [
  'node_modules',
  'coverage',
  '*.env',
  '.DS_Store',
])

VPackageJson

This represents a package.json. It is basically a VConfigFile with some useful npm-related helper methods.

It also sorts scripts, dependencies and devDependencies alphabetically on serialize, it made sense at the time.

const { VPackageJson } = require('puggle')

let pkg = new VPackageJson()

// Set the 'main' value of the package
// -> This will force it to stay as this value
pkg.addPatch('main', PatchStrategy.persist, 'src/index.js')

// Add a placeholder patch for a lint command
// -> Lets you customise the lint command later and your change is kept
pkg.addPatch('scripts', PatchStrategy.placeholder, {
  lint: 'eslint src',
})

// Add a dependancy
// -> Finds the latest version that matches your semver range
// -> IMPORTANT: this is asynchronous! It goes away to the api to fetch the version(s)
// -> There is also #addLatestDevDependencies which is the same
// -> These marked as a PatchStrategy.persist
// -> You can pass multiple packages
await pkg.addLatestDependencies({
  dotenv: '^8.x',
})

You can also use it from npmPlugin:

const { VPackageJson, npmPlugin } = require('puggle')

module.exports = {
  name: 'my-preset',
  version: '1.2.3',
  plugins: [npmPlugin],
  apply(root) {
    // Get the package.json which has already been added
    const pkg = VPackageJson.getOrFail(root)
  },
}

Npm plugin also asks extra questions to the user to fill in bits of the VPackageJson. These values get stored in the generated puggle.json so they don't need to be asked again when you do a puggle update

Publishing presets

Once you're happy with your preset, publish it to npm registry then install it globally on your dev machine.

For naming, puggle will pick up any packages that match the glob */puggle-preset*, so you could call it:

  • @org/puggle-presets
  • @user/puggle-preset-nodejs
  • puggle-preset-geoff

I'd reccomend using user-namespaced packages as presets should represent a user/orgs personal preferences.

For example: @robb_j/puggle-presets, not: puggle-preset-test.

You need to have your package.json's main set to a script which has

// Export the preset
module.exports = {
  name: 'preset',
  /* your_preset_here */
}

// Or, you can export an array of presets
module.exports = [
  { name: 'preset-a' /* your_preset_here */ },
  { name: 'preset-b' /* your_preset_here */ },
]

To publish a user-namespaced preset, follow below:

# Create a version of your plugin
npm version minor

# Publish to npm with a package like @robb_j/puggle-preset-test
npm publish --access=public

# Add your package globally to you dev machine
npm i -g @robb_j/puggle-preset-test

# Test puggle sees it
puggle test-dir

A full example

For a full example, check out my personal presets:

Types

There is a presetify function which you can use to infer types onto your preset. Without fully using typescript you can use your IDE's type support to help making presets.

It'll infer the type of the preset and the arguments to #apply too.

const { presetify } = require('puggle')

module.exports = presetify({
  /* type-hinting goodness */
})

Puggle is written in TypeScript and you have the actual types too, if you want to write your preset in TypeScript.

Future work & ideas

override PatchStrategy.persist

Some way of locking a file in puggle.json so that the local version is peristed, overriding a PatchStrategy.persist

{
  "persistFiles": [
    // Persist a specific file
    "src/somefile.js",

    // Persit a key on a VConfigFile ?
    "package.json#prettier.semi",
    { "config": "package.json", "key": "prettier.semi" },
    ["package.json", "prettier.semi"]
  ]
}

streamline nested directories

Create multiple directories at once with new VDir('some/nested/dir') type syntax ~ or even from a VFile

preview a puggle update

Generate a preview of what puggle update will do

puggle update

Will create these files:
• src/new-file.txt

These files are obsolete:
• old-config.yml

Will patch package.json
• pretter.useSemi: true => false

preview PatchStrategy.placeholder

Some way of comparing the files/values from PatchStrategy.placeholder with the live files, so you can manually update files.

extract npm & node.js logic into its own module

Extract VPackageJson and npmPlugin into a node module, making the core language-agnostic

document plugins and questions

Document how plugins work and how to ask questions in puggle init

in-project generators

puggle add route
> route name: new-route
# added src/routes/general/new-route.ts
# added src/routes/general/__test__/new-route.spec.ts
interface Generator {
  name: string
  apply(root: VDir, ctx: PluginContext)
}

interface PresetChanges extends Preset {
  generators: Generator[]
}

integrate with post-install binaries

Run npm install or git init after you've done a puggle init or puggle update. It should come from the preset rather than a default. e.g. you could have a custom first-commit message for your repo.

move to use standard-version and commitlint

When this moves to 1.x, move to use standard-version and commitlint to automatically version based on commits and generate changelogs.


This project was setup with robb-j/ts-node-base