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) | | `--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) | | `--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. | | `--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) | | `--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. | | `--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 Node.js API for more details. Instead of returning `undefined` to keep the default behavior, you can
output `''` for the same effect. 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: ## Available Commands:
| Command \| Alias | Description | | Command \| Alias | Description |

View File

@@ -29,6 +29,7 @@ interface ScaffoldConfig {
rawContent: Buffer, rawContent: Buffer,
outputPath: string, outputPath: string,
): string | Buffer | undefined | Promise<string | Buffer | undefined> ): string | Buffer | undefined | Promise<string | Buffer | undefined>
afterScaffold?: AfterScaffoldHook
} }
interface ScaffoldInput { interface ScaffoldInput {
@@ -36,6 +37,13 @@ interface ScaffoldInput {
required?: boolean required?: boolean
default?: string default?: string
} }
interface AfterScaffoldContext {
config: ScaffoldConfig
files: string[] // absolute paths of written files
}
type AfterScaffoldHook = ((context: AfterScaffoldContext) => void | Promise<void>) | string
``` ```
### Before Write option ### 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 Returning `undefined` will keep the file contents as-is, after normal Handlebars.js procesing by
Simple Scaffold. 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 ### Inputs
The `inputs` option lets you define fields that will be prompted interactively (when running in a 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 " + " file. A temporary file path will be passed to the given command and the command should " +
"return a string for the final output.", "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({ .flag({
name: "dry-run", name: "dry-run",
aliases: ["dr"], aliases: ["dr"],

View File

@@ -119,6 +119,9 @@ export async function parseConfigFile(config: ScaffoldCmdConfig): Promise<Scaffo
? wrapBeforeWrite(config, config.beforeWrite) ? wrapBeforeWrite(config, config.beforeWrite)
: undefined : undefined
output.beforeWrite = cmdBeforeWrite ?? output.beforeWrite output.beforeWrite = cmdBeforeWrite ?? output.beforeWrite
if (config.afterScaffold) {
output.afterScaffold = config.afterScaffold
}
if (!output.name) { if (!output.name) {
throw new Error("simple-scaffold: Missing required option: 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, * Processes a single template file: resolves output paths, creates directories,
* and writes the transformed output. * and writes the transformed output.
* Returns the output path if the file was written, or null if skipped.
*/ */
export async function handleTemplateFile( export async function handleTemplateFile(
config: ScaffoldConfig, config: ScaffoldConfig,
{ templatePath, basePath }: { templatePath: string; basePath: string }, { templatePath, basePath }: { templatePath: string; basePath: string },
): Promise<void> { ): Promise<string | null> {
try { try {
const { inputPath, outputPathOpt, outputDir, outputPath, exists } = await getTemplateFileInfo( const { inputPath, outputPathOpt, outputDir, outputPath, exists } = await getTemplateFileInfo(
config, config,
@@ -203,8 +204,10 @@ export async function handleTemplateFile(
await createDirIfNotExists(path.dirname(outputPath), config) await createDirIfNotExists(path.dirname(outputPath), config)
const shouldWrite = (!exists || overwrite) && !config.dryRun
log(config, LogLevel.info, `Writing to ${outputPath}`) log(config, LogLevel.info, `Writing to ${outputPath}`)
await copyFileTransformed(config, { exists, overwrite, outputPath, inputPath }) await copyFileTransformed(config, { exists, overwrite, outputPath, inputPath })
return shouldWrite ? outputPath : null
} catch (e: unknown) { } catch (e: unknown) {
handleErr(e as NodeJS.ErrnoException) handleErr(e as NodeJS.ErrnoException)
throw e throw e

View File

@@ -6,6 +6,7 @@
*/ */
import path from "node:path" import path from "node:path"
import os from "node:os" import os from "node:os"
import { exec } from "node:child_process"
import { handleErr, resolve } from "./utils" import { handleErr, resolve } from "./utils"
import { isDir, getTemplateGlobInfo, getFileList, handleTemplateFile, GlobInfo } from "./file" import { isDir, getTemplateGlobInfo, getFileList, handleTemplateFile, GlobInfo } from "./file"
@@ -53,6 +54,7 @@ export async function Scaffold(config: ScaffoldConfig): Promise<void> {
config = await resolveInputs(config) config = await resolveInputs(config)
registerHelpers(config) registerHelpers(config)
const writtenFiles: string[] = []
try { try {
config.data = { name: config.name, ...config.data } config.data = { name: config.name, ...config.data }
logInitStep(config) logInitStep(config)
@@ -63,12 +65,17 @@ export async function Scaffold(config: ScaffoldConfig): Promise<void> {
const templates = await resolveTemplateGlobs(config, includes) const templates = await resolveTemplateGlobs(config, includes)
for (const tpl of templates) { for (const tpl of templates) {
await processTemplateGlob(config, tpl, excludes) const files = await processTemplateGlob(config, tpl, excludes)
writtenFiles.push(...files)
} }
} catch (e: unknown) { } catch (e: unknown) {
log(config, LogLevel.error, e) log(config, LogLevel.error, e)
throw e throw e
} }
if (config.afterScaffold) {
await runAfterScaffoldHook(config, writtenFiles)
}
} }
/** Resolves included template paths into GlobInfo objects. */ /** Resolves included template paths into GlobInfo objects. */
@@ -87,12 +94,13 @@ async function resolveTemplateGlobs(
return templates 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( async function processTemplateGlob(
config: ScaffoldConfig, config: ScaffoldConfig,
tpl: GlobInfo, tpl: GlobInfo,
excludes: string[], excludes: string[],
): Promise<void> { ): Promise<string[]> {
const written: string[] = []
const files = await getFileList(config, [tpl.template, ...excludes]) const files = await getFileList(config, [tpl.template, ...excludes])
for (const file of files) { for (const file of files) {
if (await isDir(file)) { if (await isDir(file)) {
@@ -115,8 +123,46 @@ async function processTemplateGlob(
isGlob: tpl.isGlob, 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> 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 */ /** @internal */
tmpDir?: string 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. * Defines a single interactive input for a scaffold template.
* *
@@ -417,6 +460,8 @@ export type ScaffoldCmdConfig = {
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. */ /** 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 beforeWrite?: string
/** Run a shell command after all files have been written. Executed in the output directory. */
afterScaffold?: string
/** @internal */ /** @internal */
tmpDir?: string 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 mockFs from "mock-fs"
import FileSystem from "mock-fs/lib/filesystem" import FileSystem from "mock-fs/lib/filesystem"
import Scaffold from "../src/scaffold" import Scaffold from "../src/scaffold"
@@ -67,7 +77,8 @@ const fileStructDates = {
input: { input: {
"now.txt": "Today is {{ now 'mmm' }}, time is {{ now 'HH:mm' }}", "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' }}", "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: {}, output: {},
} }
@@ -100,7 +111,6 @@ function withMock(fileStruct: FileSystem.DirectoryItems, testFn: () => void): ()
} }
describe("Scaffold", () => { describe("Scaffold", () => {
describe( describe(
"create subdir", "create subdir",
@@ -301,7 +311,9 @@ describe("Scaffold", () => {
const rootFile = readFileSync(join(process.cwd(), "output", "app_name-1.txt")) const rootFile = readFileSync(join(process.cwd(), "output", "app_name-1.txt"))
const oneDeepFile = readFileSync(join(process.cwd(), "output", "AppName/app_name-2.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(rootFile.toString()).toEqual("This should be in root")
expect(oneDeepFile.toString()).toEqual("Hello, my value is 1") expect(oneDeepFile.toString()).toEqual("Hello, my value is 1")
expect(twoDeepFile.toString()).toEqual("Hi! My value is actually NOT 1!") expect(twoDeepFile.toString()).toEqual("Hi! My value is actually NOT 1!")
@@ -664,7 +676,9 @@ describe("Scaffold", () => {
templates: ["input"], templates: ["input"],
logLevel: "none", 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") expect(content).toEqual("export from MyComponent")
}) })
}, },
@@ -698,7 +712,9 @@ describe("Scaffold", () => {
logLevel: "none", logLevel: "none",
}) })
expect(readFileSync(join(process.cwd(), "output", "root.txt")).toString()).toEqual("root") 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( expect(
readFileSync(join(process.cwd(), "output", "level1", "level2", "l2.txt")).toString(), readFileSync(join(process.cwd(), "output", "level1", "level2", "l2.txt")).toString(),
).toEqual("level 2") ).toEqual("level 2")
@@ -734,8 +750,12 @@ describe("Scaffold", () => {
logLevel: "none", logLevel: "none",
overwrite: (_fullPath, _basedir, basename) => basename === "replace.txt", 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", "keep.txt")).toString()).toEqual(
expect(readFileSync(join(process.cwd(), "output", "replace.txt")).toString()).toEqual("new replace") "old keep",
)
expect(readFileSync(join(process.cwd(), "output", "replace.txt")).toString()).toEqual(
"new replace",
)
}) })
}, },
), ),
@@ -786,7 +806,9 @@ describe("Scaffold", () => {
subdir: true, subdir: true,
subdirHelper: "camelCase", 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") expect(content).toEqual("content")
}) })
@@ -799,7 +821,9 @@ describe("Scaffold", () => {
subdir: true, subdir: true,
subdirHelper: "kebabCase", 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") expect(content).toEqual("content")
}) })
@@ -812,7 +836,9 @@ describe("Scaffold", () => {
subdir: true, subdir: true,
subdirHelper: "snakeCase", 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") expect(content).toEqual("content")
}) })
}, },
@@ -855,10 +881,10 @@ describe("Scaffold", () => {
output: "output", output: "output",
templates: ["input"], templates: ["input"],
logLevel: "none", logLevel: "none",
data: { value: "hello & <world> \"test\"" }, data: { value: 'hello & <world> "test"' },
}) })
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString() 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"], templates: ["input"],
logLevel: "none", logLevel: "none",
}) })
expect(readFileSync(join(process.cwd(), "output", ".gitignore")).toString()).toEqual("node_modules") expect(readFileSync(join(process.cwd(), "output", ".gitignore")).toString()).toEqual(
expect(readFileSync(join(process.cwd(), "output", ".env.example")).toString()).toEqual("KEY=app") "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")) const files = readdirSync(join(process.cwd(), "output"))
expect(files.length).toBe(50) 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", "file0.txt")).toString()).toEqual(
expect(readFileSync(join(process.cwd(), "output", "file49.txt")).toString()).toEqual("Content 49 for app") "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()
}) })
}, },
), ),