mirror of
https://github.com/chenasraf/simple-scaffold.git
synced 2026-05-17 17:28:09 +00:00
feat: after scaffold hook
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
45
src/types.ts
45
src/types.ts
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user