From 0a4ead17c013b5410e8eec8000e83fa7b7186fbb Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Mon, 23 Mar 2026 16:28:40 +0200 Subject: [PATCH] feat: after scaffold hook --- docs/docs/usage/03-cli.md | 30 +++++++++ docs/docs/usage/04-node.md | 40 +++++++++++ src/cmd.ts | 8 +++ src/config.ts | 3 + src/file.ts | 5 +- src/scaffold.ts | 54 +++++++++++++-- src/types.ts | 45 +++++++++++++ tests/scaffold.test.ts | 134 ++++++++++++++++++++++++++++++++----- 8 files changed, 297 insertions(+), 22 deletions(-) diff --git a/docs/docs/usage/03-cli.md b/docs/docs/usage/03-cli.md index 17c5c32..59c2ac4 100644 --- a/docs/docs/usage/03-cli.md +++ b/docs/docs/usage/03-cli.md @@ -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 | diff --git a/docs/docs/usage/04-node.md b/docs/docs/usage/04-node.md index 50a4d46..eb60776 100644 --- a/docs/docs/usage/04-node.md +++ b/docs/docs/usage/04-node.md @@ -29,6 +29,7 @@ interface ScaffoldConfig { rawContent: Buffer, outputPath: string, ): string | Buffer | undefined | Promise + 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) | 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 diff --git a/src/cmd.ts b/src/cmd.ts index a9b538c..af8ba58 100644 --- a/src/cmd.ts +++ b/src/cmd.ts @@ -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"], diff --git a/src/config.ts b/src/config.ts index dd6e416..134b9aa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -119,6 +119,9 @@ export async function parseConfigFile(config: ScaffoldCmdConfig): Promise { +): Promise { 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 diff --git a/src/scaffold.ts b/src/scaffold.ts index 7cdfb83..0c56cc8 100644 --- a/src/scaffold.ts +++ b/src/scaffold.ts @@ -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 { 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 { 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 { +): Promise { + 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 { + 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((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) + }) } /** diff --git a/src/types.ts b/src/types.ts index 2295814..9f7ef7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -189,10 +189,53 @@ export interface ScaffoldConfig { */ inputs?: Record + /** + * 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) | 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 } diff --git a/tests/scaffold.test.ts b/tests/scaffold.test.ts index 6613ea1..3963dc4 100644 --- a/tests/scaffold.test.ts +++ b/tests/scaffold.test.ts @@ -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 & \"test\"" }, + data: { value: 'hello & "test"' }, }) const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString() - expect(content).toEqual("Value: hello & \"test\"") + expect(content).toEqual('Value: hello & "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() }) }, ),