Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 71 additions & 80 deletions packages/create-gen-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,133 +16,124 @@
<a href="https://www.npmjs.com/package/create-gen-app"><img height="20" src="https://img.shields.io/github/package-json/v/hyperweb-io/dev-utils?filename=packages%2Fcreate-gen-app%2Fpackage.json"></a>
</p>

A TypeScript library for cloning and customizing template repositories with variable replacement.
A TypeScript-first CLI/library for cloning template repositories, asking the user for variables, and generating a new project with sensible defaults.

## Features

- Clone GitHub repositories or any git URL
- Extract template variables from filenames and file contents using `__VARIABLE__` syntax
- Load custom questions from `.questions.json` or `.questions.js` files
- Interactive prompts using inquirerer with CLI argument support
- Stream-based file processing for efficient variable replacement
- Clone any Git repo (or GitHub `org/repo` shorthand) and optionally select a branch + subdirectory
- Extract template variables from filenames and file contents using the safer `____VARIABLE____` convention
- Merge auto-discovered variables with `.questions.{json,js}` (questions win, including `ignore` patterns)
- Interactive prompts powered by `inquirerer`, with CLI flag overrides (`--VAR value`) and non-TTY mode for CI
- Built-in CLI (`create-gen-app` / `cga`) that discovers templates, prompts once, and writes output safely
- License scaffolding: choose from MIT, Apache-2.0, ISC, GPL-3.0, BSD-3-Clause, Unlicense, or MPL-2.0 and generate a populated `LICENSE`

## Installation

```bash
npm install create-gen-app
# or for CLI only
npm install -g create-gen-app
```

## Usage
## CLI Usage

### Basic Usage
```bash
# interactively pick a template from launchql/pgpm-boilerplates
create-gen-app --output ./workspace

# short alias
cga --template module --branch main --output ./module \
--USERFULLNAME "Jane Dev" --USEREMAIL [email protected]

# point to a different repo/branch/path
cga --repo github:my-org/my-templates --branch release \
--path ./templates --template api --output ./api
```

Key flags:

- `--repo`, `--branch`, `--path` – choose the Git repo, branch/tag, and subdirectory that contains templates
- `--template` – folder inside `--path` (auto-prompted if omitted)
- `--output` – destination directory (defaults to `./<template>`); use `--force` to overwrite
- `--no-tty` – disable interactive prompts (ideal for CI)
- `--version`, `--help` – standard metadata
- Any extra `--VAR value` pairs become variable overrides

## Library Usage

```typescript
import { createGen } from 'create-gen-app';
import { createGen } from "create-gen-app";

await createGen({
templateUrl: 'https://github.com/user/template-repo',
outputDir: './my-new-project',
templateUrl: "https://github.com/user/template-repo",
fromBranch: "main",
fromPath: "templates/module",
outputDir: "./my-new-project",
argv: {
PROJECT_NAME: 'my-project',
AUTHOR: 'John Doe'
}
USERFULLNAME: "Jane Dev",
USEREMAIL: "[email protected]",
MODULENAME: "awesome-module",
LICENSE: "MIT",
},
noTty: true,
});
```

### Template Variables

Variables in your template should be wrapped in double underscores:
Variables should be wrapped in four underscores on each side:

**Filename variables:**
```
__PROJECT_NAME__/
__MODULE_NAME__.ts
____PROJECT_NAME____/
src/____MODULE_NAME____.ts
```

**Content variables:**
```typescript
// __MODULE_NAME__.ts
export const projectName = "__PROJECT_NAME__";
export const author = "__AUTHOR__";
// ____MODULE_NAME____.ts
export const projectName = "____PROJECT_NAME____";
export const author = "____USERFULLNAME____";
```

### Custom Questions
### Custom Questions & Ignore Rules

Create a `.questions.json` file in your template repository:
Create a `.questions.json`:

```json
{
"ignore": ["__tests__", "docs/drafts"],
"questions": [
{
"name": "PROJECT_NAME",
"name": "____USERFULLNAME____",
"type": "text",
"message": "What is your project name?",
"message": "Enter author full name",
"required": true
},
{
"name": "AUTHOR",
"type": "text",
"message": "Who is the author?"
"name": "____LICENSE____",
"type": "list",
"message": "Choose a license",
"options": ["MIT", "Apache-2.0", "ISC", "GPL-3.0"]
}
]
}
```

Or use `.questions.js` for dynamic questions:

```javascript
/**
* @typedef {Object} Questions
* @property {Array} questions - Array of question objects
*/

module.exports = {
questions: [
{
name: 'PROJECT_NAME',
type: 'text',
message: 'What is your project name?',
required: true
}
]
};
```

## API

### `createGen(options: CreateGenOptions): Promise<string>`

Main function to create a project from a template.

**Options:**
- `templateUrl` (string): URL or path to the template repository
- `outputDir` (string): Destination directory for the generated project
- `argv` (Record<string, any>): Command-line arguments to pre-populate answers
- `noTty` (boolean): Whether to disable TTY mode for non-interactive usage

### `extractVariables(templateDir: string): Promise<ExtractedVariables>`

Extract all variables from a template directory.
Or `.questions.js` for dynamic logic. Question names can use `____VAR____` or plain `VAR`; they'll be normalized automatically.

### `promptUser(extractedVariables: ExtractedVariables, argv?: Record<string, any>, noTty?: boolean): Promise<Record<string, any>>`
### License Templates

Prompt the user for variable values using inquirerer.
`create-gen-app` ships text templates in `licenses-templates/`. To add another license, drop a `.txt` file matching the desired key (e.g., `BSD-2-CLAUSE.txt`) with placeholders:

### `replaceVariables(templateDir: string, outputDir: string, extractedVariables: ExtractedVariables, answers: Record<string, any>): Promise<void>`
- `{{YEAR}}`, `{{AUTHOR}}`, `{{EMAIL_LINE}}`

Replace variables in all files and filenames.
No code changes are needed; the CLI discovers templates at runtime and will warn if a `.questions` option doesn’t have a matching template.

## Variable Naming Rules
## API Overview

Variables can contain:
- Letters (a-z, A-Z)
- Numbers (0-9)
- Underscores (_)
- Must start with a letter or underscore
- `createGen(options)` – full pipeline (clone → extract → prompt → replace)
- `cloneRepo(url, { branch })` – clone to a temp dir
- `extractVariables(dir)` – parse file/folder names + content for variables, load `.questions`
- `promptUser(extracted, argv, noTty)` – run interactive questions with CLI overrides and alias deduping
- `replaceVariables(templateDir, outputDir, extracted, answers)` – copy files, rename paths, render licenses

Examples of valid variables:
- `__PROJECT_NAME__`
- `__author__`
- `__CamelCase__`
- `__snake_case__`
- `__VERSION_1__`
See `dev/README.md` for the local development helper script (`pnpm dev`).
76 changes: 76 additions & 0 deletions packages/create-gen-app/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as fs from "fs";
import * as path from "path";

import { runCli } from "../src/cli";
import {
TEST_BRANCH,
TEST_REPO,
TEST_TEMPLATE,
buildAnswers,
cleanupWorkspace,
createTempWorkspace,
} from "../test-utils/integration-helpers";

jest.setTimeout(180_000);

describe("CLI integration (GitHub templates)", () => {
it("generates a project using the real repo", async () => {
const workspace = createTempWorkspace("cli");
const answers = buildAnswers("cli");

const args = [
"--repo",
TEST_REPO,
"--branch",
TEST_BRANCH,
"--path",
".",
"--template",
TEST_TEMPLATE,
"--output",
workspace.outputDir,
"--no-tty",
];

for (const [key, value] of Object.entries(answers)) {
args.push(`--${key}`, value);
}

try {
const result = await runCli(args);
expect(result).toBeDefined();
if (!result) {
return;
}

expect(result.template).toBe(TEST_TEMPLATE);
expect(result.outputDir).toBe(path.resolve(workspace.outputDir));

const pkgPath = path.join(workspace.outputDir, "package.json");
expect(fs.existsSync(pkgPath)).toBe(true);

const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
expect(pkg.name).toBe(answers.PACKAGE_IDENTIFIER);
expect(pkg.license).toBe(answers.LICENSE);

const licenseContent = fs.readFileSync(
path.join(workspace.outputDir, "LICENSE"),
"utf8"
);
expect(licenseContent).toContain("MIT License");
expect(licenseContent).toContain(answers.USERFULLNAME);
} finally {
cleanupWorkspace(workspace);
}
});

it("prints version and exits when --version is provided", async () => {
const logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);

await runCli(["--version"]);

expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/create-gen-app v/));
logSpy.mockRestore();
});
});

57 changes: 57 additions & 0 deletions packages/create-gen-app/__tests__/create-gen-flow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import * as fs from "fs";
import * as path from "path";

import { createGen } from "../src";
import {
TEST_BRANCH,
TEST_REPO,
TEST_TEMPLATE,
buildAnswers,
cleanupWorkspace,
createTempWorkspace,
} from "../test-utils/integration-helpers";

jest.setTimeout(180_000);

describe("createGen integration (GitHub templates)", () => {
it("clones the default repo and generates the module template", async () => {
const workspace = createTempWorkspace("flow");

try {
const answers = buildAnswers("flow");
const result = await createGen({
templateUrl: TEST_REPO,
fromBranch: TEST_BRANCH,
fromPath: TEST_TEMPLATE,
outputDir: workspace.outputDir,
argv: answers,
noTty: true,
});

expect(result).toBe(workspace.outputDir);

const packageJsonPath = path.join(workspace.outputDir, "package.json");
expect(fs.existsSync(packageJsonPath)).toBe(true);

const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
expect(pkg.name).toBe(answers.PACKAGE_IDENTIFIER);
expect(pkg.license).toBe(answers.LICENSE);
expect(pkg.author).toContain(answers.USERFULLNAME);

const questionsJsonPath = path.join(
workspace.outputDir,
".questions.json"
);
expect(fs.existsSync(questionsJsonPath)).toBe(false);

const licensePath = path.join(workspace.outputDir, "LICENSE");
expect(fs.existsSync(licensePath)).toBe(true);
const licenseContent = fs.readFileSync(licensePath, "utf8");
expect(licenseContent).toContain(answers.USERFULLNAME);
expect(licenseContent).toContain("MIT License");
} finally {
cleanupWorkspace(workspace);
}
});
});

Loading