feat: after scaffold hook

This commit is contained in:
2026-03-23 16:28:40 +02:00
parent 7926b15053
commit 0a4ead17c0
8 changed files with 297 additions and 22 deletions

View File

@@ -29,6 +29,7 @@ Options:
| `--quiet` \| `-q` | Suppress output logs (Same as `--log-level none`)(default: false) |
| `--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. (default: info) |
| `--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. |
| `--after-scaffold` \| `-A` | Run a shell command after all files have been written. The command is executed in the output directory (e.g. `--after-scaffold 'npm install'`). |
| `--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. (default: false) |
| `--version` \| `-v` | Display version. |
@@ -108,6 +109,35 @@ See
Node.js API for more details. Instead of returning `undefined` to keep the default behavior, you can
output `''` for the same effect.
### After Scaffold option
This option runs a shell command after all files have been written. The command is executed in the
output directory, making it useful for post-scaffolding tasks like installing dependencies or
initializing a git repo.
```shell
simple-scaffold -c . --after-scaffold 'npm install'
simple-scaffold -c . --after-scaffold 'git init && git add .'
```
In a config file, you can use a function for more control:
```js
module.exports = {
default: {
templates: ["templates/app"],
output: ".",
afterScaffold: async ({ config, files }) => {
console.log(`Created ${files.length} files in ${config.output}`)
// run any post-processing here
},
},
}
```
The function receives a context with the resolved `config` and an array of `files` (absolute paths)
that were written.
## Available Commands:
| Command \| Alias | Description |

View File

@@ -29,6 +29,7 @@ interface ScaffoldConfig {
rawContent: Buffer,
outputPath: string,
): string | Buffer | undefined | Promise<string | Buffer | undefined>
afterScaffold?: AfterScaffoldHook
}
interface ScaffoldInput {
@@ -36,6 +37,13 @@ interface ScaffoldInput {
required?: boolean
default?: string
}
interface AfterScaffoldContext {
config: ScaffoldConfig
files: string[] // absolute paths of written files
}
type AfterScaffoldHook = ((context: AfterScaffoldContext) => void | Promise<void>) | string
```
### Before Write option
@@ -49,6 +57,38 @@ to be used as the file contents.
Returning `undefined` will keep the file contents as-is, after normal Handlebars.js procesing by
Simple Scaffold.
### After Scaffold hook
The `afterScaffold` option runs after all files have been written. It receives a context object with
the resolved config and the list of files that were created.
```typescript
import Scaffold from "simple-scaffold"
await Scaffold({
name: "my-app",
templates: ["templates/app"],
output: ".",
afterScaffold: async ({ config, files }) => {
console.log(`Created ${files.length} files`)
// e.g. run npm install, git init, open editor, etc.
},
})
```
You can also pass a shell command string, which will be executed in the output directory:
```typescript
await Scaffold({
name: "my-app",
templates: ["templates/app"],
output: "my-app",
afterScaffold: "npm install && git init",
})
```
In dry-run mode, the hook is still called but the `files` array will be empty.
### Inputs
The `inputs` option lets you define fields that will be prompted interactively (when running in a

View File

@@ -175,6 +175,14 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
" file. A temporary file path will be passed to the given command and the command should " +
"return a string for the final output.",
})
.option({
name: "after-scaffold",
aliases: ["A"],
description:
"Run a shell command after all files have been written. " +
"The command is executed in the output directory. " +
"For example: `--after-scaffold 'npm install'`",
})
.flag({
name: "dry-run",
aliases: ["dr"],

View File

@@ -119,6 +119,9 @@ export async function parseConfigFile(config: ScaffoldCmdConfig): Promise<Scaffo
? wrapBeforeWrite(config, config.beforeWrite)
: undefined
output.beforeWrite = cmdBeforeWrite ?? output.beforeWrite
if (config.afterScaffold) {
output.afterScaffold = config.afterScaffold
}
if (!output.name) {
throw new Error("simple-scaffold: Missing required option: name")

View File

@@ -174,11 +174,12 @@ export function getOutputDir(
/**
* Processes a single template file: resolves output paths, creates directories,
* and writes the transformed output.
* Returns the output path if the file was written, or null if skipped.
*/
export async function handleTemplateFile(
config: ScaffoldConfig,
{ templatePath, basePath }: { templatePath: string; basePath: string },
): Promise<void> {
): Promise<string | null> {
try {
const { inputPath, outputPathOpt, outputDir, outputPath, exists } = await getTemplateFileInfo(
config,
@@ -203,8 +204,10 @@ export async function handleTemplateFile(
await createDirIfNotExists(path.dirname(outputPath), config)
const shouldWrite = (!exists || overwrite) && !config.dryRun
log(config, LogLevel.info, `Writing to ${outputPath}`)
await copyFileTransformed(config, { exists, overwrite, outputPath, inputPath })
return shouldWrite ? outputPath : null
} catch (e: unknown) {
handleErr(e as NodeJS.ErrnoException)
throw e

View File

@@ -6,6 +6,7 @@
*/
import path from "node:path"
import os from "node:os"
import { exec } from "node:child_process"
import { handleErr, resolve } from "./utils"
import { isDir, getTemplateGlobInfo, getFileList, handleTemplateFile, GlobInfo } from "./file"
@@ -53,6 +54,7 @@ export async function Scaffold(config: ScaffoldConfig): Promise<void> {
config = await resolveInputs(config)
registerHelpers(config)
const writtenFiles: string[] = []
try {
config.data = { name: config.name, ...config.data }
logInitStep(config)
@@ -63,12 +65,17 @@ export async function Scaffold(config: ScaffoldConfig): Promise<void> {
const templates = await resolveTemplateGlobs(config, includes)
for (const tpl of templates) {
await processTemplateGlob(config, tpl, excludes)
const files = await processTemplateGlob(config, tpl, excludes)
writtenFiles.push(...files)
}
} catch (e: unknown) {
log(config, LogLevel.error, e)
throw e
}
if (config.afterScaffold) {
await runAfterScaffoldHook(config, writtenFiles)
}
}
/** Resolves included template paths into GlobInfo objects. */
@@ -87,12 +94,13 @@ async function resolveTemplateGlobs(
return templates
}
/** Processes all files matching a single template glob pattern. */
/** Processes all files matching a single template glob pattern. Returns paths of written files. */
async function processTemplateGlob(
config: ScaffoldConfig,
tpl: GlobInfo,
excludes: string[],
): Promise<void> {
): Promise<string[]> {
const written: string[] = []
const files = await getFileList(config, [tpl.template, ...excludes])
for (const file of files) {
if (await isDir(file)) {
@@ -115,8 +123,46 @@ async function processTemplateGlob(
isGlob: tpl.isGlob,
})
await handleTemplateFile(config, { templatePath: file, basePath })
const outputPath = await handleTemplateFile(config, { templatePath: file, basePath })
if (outputPath) {
written.push(outputPath)
}
}
return written
}
/** Executes the afterScaffold hook — either a function or a shell command string. */
async function runAfterScaffoldHook(config: ScaffoldConfig, files: string[]): Promise<void> {
const hook = config.afterScaffold!
if (typeof hook === "function") {
log(config, LogLevel.debug, "Running afterScaffold function hook")
await hook({ config, files })
return
}
// Shell command string
const outputDir = typeof config.output === "string" ? config.output : process.cwd()
const cwd = path.resolve(process.cwd(), outputDir)
log(config, LogLevel.info, `Running afterScaffold command: ${hook}`)
await new Promise<void>((resolve, reject) => {
const proc = exec(hook, { cwd })
proc.stdout?.on("data", (data: string) => {
log(config, LogLevel.info, data.toString().trimEnd())
})
proc.stderr?.on("data", (data: string) => {
log(config, LogLevel.warning, data.toString().trimEnd())
})
proc.on("close", (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`afterScaffold command exited with code ${code}`))
}
})
proc.on("error", reject)
})
}
/**

View File

@@ -189,10 +189,53 @@ export interface ScaffoldConfig {
*/
inputs?: Record<string, ScaffoldInput>
/**
* A callback or shell command that runs after all files have been written.
*
* When provided as a **function** (Node.js API), it receives a context object with the scaffold
* config and the list of files that were written:
*
* ```typescript
* Scaffold({
* // ...
* afterScaffold: async ({ config, files }) => {
* console.log(`Created ${files.length} files`)
* execSync("npm install", { cwd: config.output })
* },
* })
* ```
*
* When provided as a **string** (CLI `--after` flag), it is executed as a shell command
* in the output directory after scaffolding completes.
*
* @see {@link AfterScaffoldContext}
*/
afterScaffold?: AfterScaffoldHook
/** @internal */
tmpDir?: string
}
/**
* Context passed to the {@link ScaffoldConfig.afterScaffold} hook.
*
* @category Config
*/
export interface AfterScaffoldContext {
/** The resolved scaffold config that was used. */
config: ScaffoldConfig
/** List of absolute paths to files that were written. */
files: string[]
}
/**
* A hook that runs after scaffolding completes.
* Can be a function receiving context, or a shell command string.
*
* @category Config
*/
export type AfterScaffoldHook = ((context: AfterScaffoldContext) => void | Promise<void>) | string
/**
* Defines a single interactive input for a scaffold template.
*
@@ -417,6 +460,8 @@ export type ScaffoldCmdConfig = {
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
/** Run a shell command after all files have been written. Executed in the output directory. */
afterScaffold?: string
/** @internal */
tmpDir?: string
}

View File

@@ -1,4 +1,14 @@
import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll, vi, type MockInstance } from "vitest"
import {
describe,
test,
expect,
beforeEach,
afterEach,
beforeAll,
afterAll,
vi,
type MockInstance,
} from "vitest"
import mockFs from "mock-fs"
import FileSystem from "mock-fs/lib/filesystem"
import Scaffold from "../src/scaffold"
@@ -67,7 +77,8 @@ const fileStructDates = {
input: {
"now.txt": "Today is {{ now 'mmm' }}, time is {{ now 'HH:mm' }}",
"offset.txt": "Yesterday was {{ now 'mmm' -1 'days' }}, time is {{ now 'HH:mm' -1 'days' }}",
"custom.txt": "Custom date is {{ date customDate 'mmm' }}, time is {{ date customDate 'HH:mm' }}",
"custom.txt":
"Custom date is {{ date customDate 'mmm' }}, time is {{ date customDate 'HH:mm' }}",
},
output: {},
}
@@ -100,7 +111,6 @@ function withMock(fileStruct: FileSystem.DirectoryItems, testFn: () => void): ()
}
describe("Scaffold", () => {
describe(
"create subdir",
@@ -301,7 +311,9 @@ describe("Scaffold", () => {
const rootFile = readFileSync(join(process.cwd(), "output", "app_name-1.txt"))
const oneDeepFile = readFileSync(join(process.cwd(), "output", "AppName/app_name-2.txt"))
const twoDeepFile = readFileSync(join(process.cwd(), "output", "AppName/moreNesting/app_name-3.txt"))
const twoDeepFile = readFileSync(
join(process.cwd(), "output", "AppName/moreNesting/app_name-3.txt"),
)
expect(rootFile.toString()).toEqual("This should be in root")
expect(oneDeepFile.toString()).toEqual("Hello, my value is 1")
expect(twoDeepFile.toString()).toEqual("Hi! My value is actually NOT 1!")
@@ -664,7 +676,9 @@ describe("Scaffold", () => {
templates: ["input"],
logLevel: "none",
})
const content = readFileSync(join(process.cwd(), "output", "my-component", "index.ts")).toString()
const content = readFileSync(
join(process.cwd(), "output", "my-component", "index.ts"),
).toString()
expect(content).toEqual("export from MyComponent")
})
},
@@ -698,7 +712,9 @@ describe("Scaffold", () => {
logLevel: "none",
})
expect(readFileSync(join(process.cwd(), "output", "root.txt")).toString()).toEqual("root")
expect(readFileSync(join(process.cwd(), "output", "level1", "l1.txt")).toString()).toEqual("level 1")
expect(
readFileSync(join(process.cwd(), "output", "level1", "l1.txt")).toString(),
).toEqual("level 1")
expect(
readFileSync(join(process.cwd(), "output", "level1", "level2", "l2.txt")).toString(),
).toEqual("level 2")
@@ -734,8 +750,12 @@ describe("Scaffold", () => {
logLevel: "none",
overwrite: (_fullPath, _basedir, basename) => basename === "replace.txt",
})
expect(readFileSync(join(process.cwd(), "output", "keep.txt")).toString()).toEqual("old keep")
expect(readFileSync(join(process.cwd(), "output", "replace.txt")).toString()).toEqual("new replace")
expect(readFileSync(join(process.cwd(), "output", "keep.txt")).toString()).toEqual(
"old keep",
)
expect(readFileSync(join(process.cwd(), "output", "replace.txt")).toString()).toEqual(
"new replace",
)
})
},
),
@@ -786,7 +806,9 @@ describe("Scaffold", () => {
subdir: true,
subdirHelper: "camelCase",
})
const content = readFileSync(join(process.cwd(), "output", "myComponent", "file.txt")).toString()
const content = readFileSync(
join(process.cwd(), "output", "myComponent", "file.txt"),
).toString()
expect(content).toEqual("content")
})
@@ -799,7 +821,9 @@ describe("Scaffold", () => {
subdir: true,
subdirHelper: "kebabCase",
})
const content = readFileSync(join(process.cwd(), "output", "my-component", "file.txt")).toString()
const content = readFileSync(
join(process.cwd(), "output", "my-component", "file.txt"),
).toString()
expect(content).toEqual("content")
})
@@ -812,7 +836,9 @@ describe("Scaffold", () => {
subdir: true,
subdirHelper: "snakeCase",
})
const content = readFileSync(join(process.cwd(), "output", "my_component", "file.txt")).toString()
const content = readFileSync(
join(process.cwd(), "output", "my_component", "file.txt"),
).toString()
expect(content).toEqual("content")
})
},
@@ -855,10 +881,10 @@ describe("Scaffold", () => {
output: "output",
templates: ["input"],
logLevel: "none",
data: { value: "hello & <world> \"test\"" },
data: { value: 'hello & <world> "test"' },
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual("Value: hello & <world> \"test\"")
expect(content).toEqual('Value: hello & <world> "test"')
})
},
),
@@ -1052,8 +1078,12 @@ describe("Scaffold", () => {
templates: ["input"],
logLevel: "none",
})
expect(readFileSync(join(process.cwd(), "output", ".gitignore")).toString()).toEqual("node_modules")
expect(readFileSync(join(process.cwd(), "output", ".env.example")).toString()).toEqual("KEY=app")
expect(readFileSync(join(process.cwd(), "output", ".gitignore")).toString()).toEqual(
"node_modules",
)
expect(readFileSync(join(process.cwd(), "output", ".env.example")).toString()).toEqual(
"KEY=app",
)
})
},
),
@@ -1078,8 +1108,78 @@ describe("Scaffold", () => {
})
const files = readdirSync(join(process.cwd(), "output"))
expect(files.length).toBe(50)
expect(readFileSync(join(process.cwd(), "output", "file0.txt")).toString()).toEqual("Content 0 for app")
expect(readFileSync(join(process.cwd(), "output", "file49.txt")).toString()).toEqual("Content 49 for app")
expect(readFileSync(join(process.cwd(), "output", "file0.txt")).toString()).toEqual(
"Content 0 for app",
)
expect(readFileSync(join(process.cwd(), "output", "file49.txt")).toString()).toEqual(
"Content 49 for app",
)
})
},
),
)
describe(
"afterScaffold hook",
withMock(
{
input: { "file.txt": "Hello {{name}}" },
output: {},
},
() => {
test("calls function hook with config and files", async () => {
const hookFn = vi.fn()
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
afterScaffold: hookFn,
})
expect(hookFn).toHaveBeenCalledOnce()
const ctx = hookFn.mock.calls[0][0]
expect(ctx.config.name).toEqual("app")
expect(ctx.files.length).toBe(1)
expect(ctx.files[0]).toContain("file.txt")
})
test("calls async function hook", async () => {
let called = false
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
afterScaffold: async () => {
called = true
},
})
expect(called).toBe(true)
})
test("does not call hook when no files written (dry run)", async () => {
const hookFn = vi.fn()
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
dryRun: true,
afterScaffold: hookFn,
})
expect(hookFn).toHaveBeenCalledOnce()
expect(hookFn.mock.calls[0][0].files.length).toBe(0)
})
test("does not call hook when not provided", async () => {
await expect(
Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
}),
).resolves.toBeUndefined()
})
},
),