feat: add --before-write cli option (#89)

* feat: add `--before-write` cli option

* refactor: cleanup before write wrapper

* docs: add before-write docs
This commit is contained in:
Chen Asraf
2024-02-24 00:38:29 +02:00
parent d579c09c11
commit 5f810e2116
9 changed files with 180 additions and 35 deletions

View File

@@ -12,7 +12,7 @@ To see this and more information anytime, add the `-h` or `--help` flag to your
`npx simple-scaffold@latest -h`. `npx simple-scaffold@latest -h`.
| Command \| alias | | | Command \| alias | |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--name` \| `-n` | Name to be passed to the generated files. `{{name}}` and other data parameters inside contents and file names will be replaced accordingly. You may omit the `--name` or `-n` for this specific option. | | `--name` \| `-n` | Name to be passed to the generated files. `{{name}}` and other data parameters inside contents and file names will be replaced accordingly. You may omit the `--name` or `-n` for this specific option. |
| `--config`\|`-c` | Filename or directory to load config from | | `--config`\|`-c` | Filename or directory to load config from |
| `--git`\|`-g` | Git URL or GitHub path to load a template from. | | `--git`\|`-g` | Git URL or GitHub path to load a template from. |
@@ -26,10 +26,43 @@ To see this and more information anytime, add the `-h` or `--help` flag to your
| `--sub-folder-name-helper` \| `-sh` | Default helper to apply to subfolder name when using `--create-sub-folder true`. | | `--sub-folder-name-helper` \| `-sh` | Default helper to apply to subfolder name when using `--create-sub-folder true`. |
| `--quiet` \| `-q` | Suppress output logs (Same as `--log-level none`) | | `--quiet` \| `-q` | Suppress output logs (Same as `--log-level none`) |
| `--log-level` \| `-l` | Determine amount of logs to display. The values are: `none \| debug \| info \| warn \| error`. The provided level will display messages of the same level or higher. | | `--log-level` \| `-l` | Determine amount of logs to display. The values are: `none \| debug \| info \| warn \| error`. The provided level will display messages of the same level or higher. |
| `--before-write` \| `-B` | Run a script before writing the files. This can be a command or a path to a file. A temporary file path will be passed to the given command and the command should return a string for the final output. |
| `--dry-run` \| `-dr` | Don't emit files. This is good for testing your scaffolds and making sure they don't fail, without having to write actual file contents or create directories. | | `--dry-run` \| `-dr` | Don't emit files. This is good for testing your scaffolds and making sure they don't fail, without having to write actual file contents or create directories. |
| `--help` \| `-h` | Show this help message | | `--help` \| `-h` | Show this help message |
| `--version` \| `-v` | Display version. | | `--version` \| `-v` | Display version. |
### Before Write option
This option allows you to preprocess a file before it is being written, such as running a formatter,
linter or other commands.
To use this option, pass it the command you would like to run. The following tokens will be replaced
in your string:
- `{{path}}` - the temporary file path for you to read from
- `{{rawpath}}` - a different file path containing the raw file contents **before** they were
handled by Handlebars.js.
If none of these tokens are found, the regular (non-raw) path will be appended to the end of the
command.
```shell
simple-scaffold -c . --before-write prettier
# command: prettier /tmp/somefile
simple-scaffold -c . --before-write 'cat {{path}} | my-linter'
# command: cat /tmp/somefile | my-linter
```
The command should return the string to write to the file through standard output (stdout), and not
re-write the tmp file as it is not used for writing. Returning an empty string (after trimming) will
discard the result and write the original file contents.
See
[beforeWrite](https://chenasraf.github.io/simple-scaffold/docs/api/interfaces/ScaffoldConfig#beforewrite)
Node.js API for more details. Instead of returning `undefined` to keep the default behavior, you can
output `''` for the same effect.
## Examples: ## Examples:
> See > See

View File

@@ -2,6 +2,8 @@
title: Node.js Usage title: Node.js Usage
--- ---
## Overview
You can build the scaffold yourself, if you want to create more complex arguments, scaffold groups, You can build the scaffold yourself, if you want to create more complex arguments, scaffold groups,
etc - simply pass a config object to the Scaffold function when you are ready to start. etc - simply pass a config object to the Scaffold function when you are ready to start.
@@ -33,6 +35,19 @@ interface ScaffoldConfig {
} }
``` ```
### Before Write option
This option allows you to preprocess a file before it is being written, such as running a formatter,
linter or other commands.
To use this option, you can run any async/blocking command, and return a string as the final output
to be used as the file contents.
Returning `undefined` will keep the file contents as-is, after normal Handlebars.js procesing by
Simple Scaffold.
## Example
This is an example of loading a complete scaffold via Node.js: This is an example of loading a complete scaffold via Node.js:
```typescript ```typescript
@@ -50,6 +65,8 @@ const config = {
helpers: { helpers: {
twice: (text) => [text, text].join(" ") twice: (text) => [text, text].join(" ")
}, },
// return a string to replace the final file contents after pre-processing, or `undefined`
// to keep it as-is
beforeWrite: (content, rawContent, outputPath) => content.toString().toUpperCase() beforeWrite: (content, rawContent, outputPath) => content.toString().toUpperCase()
} }

View File

@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react"
import * as css from "./{{pascalCae name}}.css" import * as css from "./{{pascalCase name}}.css"
class {{pascalCae name}} extends React.Component<any> { class {{pascalCase name}} extends React.Component<any> {
private {{ property }} private {{ property }}
constructor(props: any) { constructor(props: any) {
@@ -10,8 +10,8 @@ class {{pascalCae name}} extends React.Component<any> {
} }
public render() { public render() {
return <div className={ css.{{pascalCae name}} } /> return <div className={ css.{{pascalCase name}} } />
} }
} }
export default {pascalCae nName}} export default {{pascalCase name}}

View File

@@ -1,15 +1,15 @@
#!/usr/bin/env node #!/usr/bin/env node
import os from "node:os" import path from "node:path"
import fs from "node:fs/promises"
import { massarg } from "massarg" import { massarg } from "massarg"
import chalk from "chalk" import chalk from "chalk"
import { ListCommandCliOptions, LogLevel, ScaffoldCmdConfig } from "./types" import { ListCommandCliOptions, LogLevel, ScaffoldCmdConfig } from "./types"
import { Scaffold } from "./scaffold" import { Scaffold } from "./scaffold"
import path from "node:path"
import fs from "node:fs/promises"
import { getConfigFile, parseAppendData, parseConfigFile } from "./config" import { getConfigFile, parseAppendData, parseConfigFile } from "./config"
import { log } from "./logger" import { log } from "./logger"
import { MassargCommand } from "massarg/command" import { MassargCommand } from "massarg/command"
import { getUniqueTmpPath as generateUniqueTmpPath } from "./file"
export async function parseCliArgs(args = process.argv.slice(2)) { export async function parseCliArgs(args = process.argv.slice(2)) {
const isProjectRoot = Boolean(await fs.stat(path.join(__dirname, "package.json")).catch(() => false)) const isProjectRoot = Boolean(await fs.stat(path.join(__dirname, "package.json")).catch(() => false))
@@ -30,7 +30,7 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
return return
} }
log(config, LogLevel.info, `Simple Scaffold v${pkg.version}`) log(config, LogLevel.info, `Simple Scaffold v${pkg.version}`)
const tmpPath = path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}`) const tmpPath = generateUniqueTmpPath()
try { try {
log(config, LogLevel.debug, "Parsing config file...", config) log(config, LogLevel.debug, "Parsing config file...", config)
const parsed = await parseConfigFile(config, tmpPath) const parsed = await parseConfigFile(config, tmpPath)
@@ -144,6 +144,14 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
return val return val
}, },
}) })
.option({
name: "before-write",
aliases: ["B"],
description:
"Run a script before writing the files. This can be a command or a path to a" +
" file. A temporary file path will be passed to the given command and the command should " +
"return a string for the final output.",
})
.flag({ .flag({
name: "dry-run", name: "dry-run",
aliases: ["dr"], aliases: ["dr"],
@@ -163,7 +171,7 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
aliases: ["ls"], aliases: ["ls"],
description: "List all available templates for a given config. See `list -h` for more information.", description: "List all available templates for a given config. See `list -h` for more information.",
run: async (_config) => { run: async (_config) => {
const tmpPath = path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}`) const tmpPath = generateUniqueTmpPath()
const config = { const config = {
templates: [], templates: [],
name: "", name: "",

View File

@@ -16,7 +16,8 @@ import { handlebarsParse } from "./parser"
import { log } from "./logger" import { log } from "./logger"
import { resolve, wrapNoopResolver } from "./utils" import { resolve, wrapNoopResolver } from "./utils"
import { getGitConfig } from "./git" import { getGitConfig } from "./git"
import { isDir, pathExists } from "./file" import { createDirIfNotExists, getUniqueTmpPath, isDir, pathExists } from "./file"
import { exec, spawn } from "node:child_process"
/** @internal */ /** @internal */
export function getOptionValueForFile<T>( export function getOptionValueForFile<T>(
@@ -80,7 +81,7 @@ export async function getConfigFile(config: ScaffoldCmdConfig, tmpPath: string):
/** @internal */ /** @internal */
export async function parseConfigFile(config: ScaffoldCmdConfig, tmpPath: string): Promise<ScaffoldConfig> { export async function parseConfigFile(config: ScaffoldCmdConfig, tmpPath: string): Promise<ScaffoldConfig> {
let output: ScaffoldConfig = config let output: ScaffoldConfig = { ...config, beforeWrite: undefined }
if (config.quiet) { if (config.quiet) {
config.logLevel = LogLevel.none config.logLevel = LogLevel.none
@@ -101,6 +102,7 @@ export async function parseConfigFile(config: ScaffoldCmdConfig, tmpPath: string
output = { output = {
...config, ...config,
...imported, ...imported,
beforeWrite: undefined,
data: { data: {
...(imported as any).data, ...(imported as any).data,
...config.data, ...config.data,
@@ -109,9 +111,12 @@ export async function parseConfigFile(config: ScaffoldCmdConfig, tmpPath: string
} }
output.data = { ...output.data, ...config.appendData } output.data = { ...output.data, ...config.appendData }
output.beforeWrite = config.beforeWrite ? wrapBeforeWrite(config, config.beforeWrite) : undefined
if (!output.name) { if (!output.name) {
throw new Error("simple-scaffold: Missing required option: name") throw new Error("simple-scaffold: Missing required option: name")
} }
log(output, LogLevel.debug, "Parsed config", output) log(output, LogLevel.debug, "Parsed config", output)
return output return output
} }
@@ -182,3 +187,72 @@ export async function findConfigFile(root: string): Promise<string> {
} }
throw new Error(`Could not find config file in git repo`) throw new Error(`Could not find config file in git repo`)
} }
function wrapBeforeWrite(
config: LogConfig & Pick<ScaffoldConfig, "dryRun">,
beforeWrite: string,
): ScaffoldConfig["beforeWrite"] {
return async (content, rawContent, outputFile) => {
const tmpPath = path.join(getUniqueTmpPath(), path.basename(outputFile))
await createDirIfNotExists(path.dirname(tmpPath), config)
const ext = path.extname(outputFile)
const rawTmpPath = tmpPath.replace(ext, ".raw" + ext)
try {
log(config, LogLevel.debug, "Parsing beforeWrite command", beforeWrite)
let cmd = await prepareBeforeWriteCmd({ beforeWrite, tmpPath, content, rawTmpPath, rawContent })
const result = await new Promise<string | undefined>((resolve, reject) => {
log(config, LogLevel.debug, "Running parsed beforeWrite command:", cmd)
const proc = exec(cmd)
proc.stdout!.on("data", (data) => {
if (data.trim()) {
resolve(data.toString())
} else {
resolve(undefined)
}
})
proc.stderr!.on("data", (data) => {
reject(data.toString())
})
})
return result
} catch (e) {
log(config, LogLevel.debug, e)
log(config, LogLevel.warning, "Error running beforeWrite command, returning original content")
return undefined
} finally {
await fs.rm(tmpPath, { force: true })
await fs.rm(rawTmpPath, { force: true })
}
}
}
async function prepareBeforeWriteCmd({
beforeWrite,
tmpPath,
content,
rawTmpPath,
rawContent,
}: {
beforeWrite: string
tmpPath: string
content: Buffer
rawTmpPath: string
rawContent: Buffer
}): Promise<string> {
let cmd: string = ""
const pathReg = /\{\{\s*path\s*\}\}/gi
const rawPathReg = /\{\{\s*rawpath\s*\}\}/gi
if (pathReg.test(beforeWrite)) {
await fs.writeFile(tmpPath, content)
cmd = beforeWrite.replaceAll(pathReg, tmpPath)
}
if (rawPathReg.test(beforeWrite)) {
await fs.writeFile(rawTmpPath, rawContent)
cmd = beforeWrite.replaceAll(rawPathReg, rawTmpPath)
}
if (!cmd) {
await fs.writeFile(tmpPath, content)
cmd = [beforeWrite, tmpPath].join(" ")
}
return cmd
}

View File

@@ -1,7 +1,8 @@
import os from "node:os"
import path from "node:path" import path from "node:path"
import { F_OK } from "node:constants"
import { LogLevel, ScaffoldConfig } from "./types"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import { F_OK } from "node:constants"
import { LogConfig, LogLevel, ScaffoldConfig } from "./types"
import { glob, hasMagic } from "glob" import { glob, hasMagic } from "glob"
import { log } from "./logger" import { log } from "./logger"
import { getOptionValueForFile } from "./config" import { getOptionValueForFile } from "./config"
@@ -10,7 +11,10 @@ import { handleErr } from "./utils"
const { stat, access, mkdir, readFile, writeFile } = fs const { stat, access, mkdir, readFile, writeFile } = fs
export async function createDirIfNotExists(dir: string, config: ScaffoldConfig): Promise<void> { export async function createDirIfNotExists(
dir: string,
config: LogConfig & Pick<ScaffoldConfig, "dryRun">,
): Promise<void> {
if (config.dryRun) { if (config.dryRun) {
log(config, LogLevel.info, `Dry Run. Not creating dir ${dir}`) log(config, LogLevel.info, `Dry Run. Not creating dir ${dir}`)
return return
@@ -142,6 +146,7 @@ export async function copyFileTransformed(
if (exists && overwrite) { if (exists && overwrite) {
log(config, LogLevel.info, `File ${outputPath} exists, overwriting`) log(config, LogLevel.info, `File ${outputPath} exists, overwriting`)
} }
log(config, LogLevel.debug, `Processing file ${inputPath}`)
const templateBuffer = await readFile(inputPath) const templateBuffer = await readFile(inputPath)
const unprocessedOutputContents = handlebarsParse(config, templateBuffer) const unprocessedOutputContents = handlebarsParse(config, templateBuffer)
const finalOutputContents = const finalOutputContents =
@@ -149,7 +154,6 @@ export async function copyFileTransformed(
if (!config.dryRun) { if (!config.dryRun) {
await writeFile(outputPath, finalOutputContents) await writeFile(outputPath, finalOutputContents)
log(config, LogLevel.info, "Done.")
} else { } else {
log(config, LogLevel.info, "Dry Run. Output should be:") log(config, LogLevel.info, "Dry Run. Output should be:")
log(config, LogLevel.info, finalOutputContents.toString()) log(config, LogLevel.info, finalOutputContents.toString())
@@ -157,6 +161,7 @@ export async function copyFileTransformed(
} else if (exists) { } else if (exists) {
log(config, LogLevel.info, `File ${outputPath} already exists, skipping`) log(config, LogLevel.info, `File ${outputPath} already exists, skipping`)
} }
log(config, LogLevel.info, "Done.")
} }
export function getOutputDir(config: ScaffoldConfig, outputPathOpt: string, basePath: string): string { export function getOutputDir(config: ScaffoldConfig, outputPathOpt: string, basePath: string): string {
@@ -209,3 +214,8 @@ export async function handleTemplateFile(
} }
}) })
} }
/** @internal */
export function getUniqueTmpPath(): string {
return path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}-${Math.random().toString(36).slice(2)}`)
}

View File

@@ -1,3 +1,4 @@
import util from "util"
import { LogConfig, LogLevel, ScaffoldConfig } from "./types" import { LogConfig, LogLevel, ScaffoldConfig } from "./types"
import chalk from "chalk" import chalk from "chalk"
@@ -30,7 +31,7 @@ export function log(config: LogConfig, level: LogLevel, ...obj: any[]): void {
i instanceof Error i instanceof Error
? chalkFn(i, JSON.stringify(i, undefined, 1), i.stack) ? chalkFn(i, JSON.stringify(i, undefined, 1), i.stack)
: typeof i === "object" : typeof i === "object"
? chalkFn(JSON.stringify(i, undefined, 1)) ? util.inspect(i, { depth: null, colors: true })
: chalkFn(i), : chalkFn(i),
), ),
) )

View File

@@ -121,7 +121,7 @@ export function handlebarsParse(
return Buffer.from(outputContents) return Buffer.from(outputContents)
} catch (e) { } catch (e) {
log(config, LogLevel.debug, e) log(config, LogLevel.debug, e)
log(config, LogLevel.info, "Couldn't parse file with handlebars, returning original content") log(config, LogLevel.warning, "Couldn't parse file with handlebars, returning original content")
return Buffer.from(templateBuffer) return Buffer.from(templateBuffer)
} }
} }

View File

@@ -368,6 +368,8 @@ export type ScaffoldCmdConfig = {
git?: string git?: string
/** Display version */ /** Display version */
version: boolean version: boolean
/** Run a script before writing the files. This can be a command or a path to a file. The file contents will be passed to the given command. */
beforeWrite?: string
} }
/** /**